diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0780e34e..81f1e3f9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -7,7 +7,6 @@ on: jobs: build: - if: github.event.pull_request.draft == false name: Build runs-on: ubuntu-latest @@ -29,11 +28,7 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive - - - name: Get dependencies - run: | - go get -v -t -d ./... - + - name: Lint uses: golangci/golangci-lint-action@v6 diff --git a/.golangci.yml b/.golangci.yml index 0f388ec2..2115fea3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,8 @@ linters-settings: misspell: locale: UK + ignore-words: + - standardize # hujson library uses US spelling linters: enable: - contextcheck diff --git a/README.md b/README.md index 9a3dc367..97d6481a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Go](https://github.com/flagsmith/flagsmith-go-client/workflows/Go/badge.svg?branch=main)](https://github.com/flagsmith/flagsmith-go-client/actions) [![GoReportCard](https://goreportcard.com/badge/github.com/flagsmith/flagsmith-go-client)](https://goreportcard.com/report/github.com/flagsmith/flagsmith-go-client) -[![GoDoc](https://godoc.org/github.com/flagsmith/flagsmith-go-client/v4?status.svg)](https://pkg.go.dev/github.com/Flagsmith/flagsmith-go-client/v4#section-documentation) +[![GoDoc](https://godoc.org/github.com/flagsmith/flagsmith-go-client/v5?status.svg)](https://pkg.go.dev/github.com/Flagsmith/flagsmith-go-client/v5#section-documentation) # Flagsmith Go SDK diff --git a/client.go b/client.go index d7a573ec..06adf798 100644 --- a/client.go +++ b/client.go @@ -11,13 +11,11 @@ import ( "sync/atomic" "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" "github.com/go-resty/resty/v2" - - enginetraits "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" ) type contextKey string @@ -30,7 +28,7 @@ type Client struct { config config environment atomic.Value - identitiesWithOverrides atomic.Value + engineEvaluationContext atomic.Value analyticsProcessor *AnalyticsProcessor realtime *realtime @@ -141,7 +139,10 @@ func NewClient(apiKey string, options ...Option) *Client { panic("local evaluation and offline handler cannot be used together.") } if c.offlineHandler != nil { - c.environment.Store(c.offlineHandler.GetEnvironment()) + env := c.offlineHandler.GetEnvironment() + c.environment.Store(env) + engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) + c.engineEvaluationContext.Store(&engineEvalCtx) } if c.config.localEvaluation { @@ -230,9 +231,10 @@ func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits // Returns an array of segments that the given identity is part of. func (c *Client) GetIdentitySegments(identifier string, traits []*Trait) ([]*segments.SegmentModel, error) { - if env, ok := c.environment.Load().(*environments.EnvironmentModel); ok { - identity := c.getIdentityModel(identifier, env.APIKey, traits) - return flagengine.GetIdentitySegments(env, &identity), nil + if evalCtx, ok := c.engineEvaluationContext.Load().(*engine_eval.EngineEvaluationContext); ok { + engineEvalCtx := engine_eval.MapContextAndIdentityDataToContext(*evalCtx, identifier, traits) + result := flagengine.GetEvaluationResult(&engineEvalCtx) + return engine_eval.MapEvaluationResultSegmentsToSegmentModels(&result), nil } return nil, &FlagsmithClientError{msg: "flagsmith: Local evaluation required to obtain identity segments"} } @@ -333,32 +335,22 @@ func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, } func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits []*Trait) (Flags, error) { - env, ok := c.environment.Load().(*environments.EnvironmentModel) + evalCtx, ok := c.engineEvaluationContext.Load().(*engine_eval.EngineEvaluationContext) if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } - identity := c.getIdentityModel(identifier, env.APIKey, traits) - featureStates := flagengine.GetIdentityFeatureStates(env, &identity) - flags := makeFlagsFromFeatureStates( - featureStates, - c.analyticsProcessor, - c.defaultFlagHandler, - identifier, - ) - return flags, nil + engineEvalCtx := engine_eval.MapContextAndIdentityDataToContext(*evalCtx, identifier, traits) + result := flagengine.GetEvaluationResult(&engineEvalCtx) + return makeFlagsFromEngineEvaluationResult(&result, c.analyticsProcessor, c.defaultFlagHandler), nil } func (c *Client) getEnvironmentFlagsFromEnvironment() (Flags, error) { - env, ok := c.environment.Load().(*environments.EnvironmentModel) + evalCtx, ok := c.engineEvaluationContext.Load().(*engine_eval.EngineEvaluationContext) if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } - return makeFlagsFromFeatureStates( - env.FeatureStates, - c.analyticsProcessor, - c.defaultFlagHandler, - "", - ), nil + result := flagengine.GetEvaluationResult(evalCtx) + return makeFlagsFromEngineEvaluationResult(&result, c.analyticsProcessor, c.defaultFlagHandler), nil } func (c *Client) pollEnvironment(ctx context.Context, pollForever bool) { @@ -461,11 +453,9 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { isNew = true } c.environment.Store(&env) - identitiesWithOverrides := make(map[string]identities.IdentityModel) - for _, id := range env.IdentityOverrides { - identitiesWithOverrides[id.Identifier] = *id - } - c.identitiesWithOverrides.Store(identitiesWithOverrides) + + engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(&env) + c.engineEvaluationContext.Store(&engineEvalCtx) if isNew { c.log.Info("environment updated", "environment", env.APIKey, "updated_at", env.UpdatedAt) @@ -473,23 +463,3 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { return nil } - -func (c *Client) getIdentityModel(identifier string, apiKey string, traits []*Trait) identities.IdentityModel { - identityTraits := make([]*enginetraits.TraitModel, len(traits)) - for i, trait := range traits { - identityTraits[i] = trait.ToTraitModel() - } - - identitiesWithOverrides, _ := c.identitiesWithOverrides.Load().(map[string]identities.IdentityModel) - identity, ok := identitiesWithOverrides[identifier] - if ok { - identity.IdentityTraits = identityTraits - return identity - } - - return identities.IdentityModel{ - Identifier: identifier, - IdentityTraits: identityTraits, - EnvironmentAPIKey: apiKey, - } -} diff --git a/client_test.go b/client_test.go index ab916227..cf132ea3 100644 --- a/client_test.go +++ b/client_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - flagsmith "github.com/Flagsmith/flagsmith-go-client/v4" - "github.com/Flagsmith/flagsmith-go-client/v4/fixtures" + flagsmith "github.com/Flagsmith/flagsmith-go-client/v5" + "github.com/Flagsmith/flagsmith-go-client/v5/fixtures" "github.com/go-resty/resty/v2" "github.com/stretchr/testify/assert" ) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index f9877115..41c20214 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit f987711516f088897f08b4fb8ffc06383e1ad547 +Subproject commit 41c202145e375c712600e318c439456de5b221d7 diff --git a/flagengine/engine.go b/flagengine/engine.go index 3f28f7fc..6b06f18d 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -1,115 +1,181 @@ package flagengine import ( - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "fmt" + "math" + "sort" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) -// GetEnvironmentFeatureStates returns a list of feature states for a given environment. -func GetEnvironmentFeatureStates(environment *environments.EnvironmentModel) []*features.FeatureStateModel { - if environment.Project.HideDisabledFlags { - var featureStates []*features.FeatureStateModel - for _, fs := range environment.FeatureStates { - if fs.Enabled { - featureStates = append(featureStates, fs) - } - } - return featureStates - } - return environment.FeatureStates +type featureContextWithSegmentName struct { + featureContext *engine_eval.FeatureContext + segmentName string } -// GetEnvironmentFeatureState returns a specific feature state for a given featureName in a given environment, or nil feature state is not found. -func GetEnvironmentFeatureState(environment *environments.EnvironmentModel, featureName string) *features.FeatureStateModel { - for _, fs := range environment.FeatureStates { - if fs.Feature.Name == featureName { - return fs - } +func getPriorityOrDefault(priority *float64) float64 { + if priority != nil { + return *priority } - return nil + return math.Inf(1) // Weakest possible priority } -// GetIdentityFeatureStates returns a list of feature states for a given identity in a given environment. -func GetIdentityFeatureStates( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - overrideTraits ...*traits.TraitModel, -) []*features.FeatureStateModel { - featureStatesMap := getIdentityFeatureStatesMap(environment, identity, overrideTraits...) - featureStates := make([]*features.FeatureStateModel, 0, len(featureStatesMap)) - hideDisabled := environment.Project.HideDisabledFlags - for _, fs := range featureStatesMap { - if hideDisabled && !fs.Enabled { +func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { + segments := []engine_eval.SegmentResult{} + segmentFeatureContexts := make(map[string]featureContextWithSegmentName) + + // Get sorted segment keys for deterministic ordering + segmentKeys := make([]string, 0, len(ec.Segments)) + for key := range ec.Segments { + segmentKeys = append(segmentKeys, key) + } + sort.Strings(segmentKeys) + + // Process segments in sorted order + for _, key := range segmentKeys { + segmentContext := ec.Segments[key] + if !engine_eval.IsContextInSegment(ec, &segmentContext) { continue } - featureStates = append(featureStates, fs) - } - return featureStates -} + // Add segment to results + segments = append(segments, engine_eval.SegmentResult{ + Key: segmentContext.Key, + Name: segmentContext.Name, + Metadata: segmentContext.Metadata, + }) + + // Process segment overrides + if segmentContext.Overrides != nil { + for i := range segmentContext.Overrides { + override := &segmentContext.Overrides[i] + featureKey := override.FeatureKey + + // Check if we should update the segment feature context + shouldUpdate := false + if existing, exists := segmentFeatureContexts[featureKey]; !exists { + shouldUpdate = true + } else { + existingPriority := getPriorityOrDefault(existing.featureContext.Priority) + overridePriority := getPriorityOrDefault(override.Priority) + if overridePriority < existingPriority { + shouldUpdate = true + } + } -func GetIdentityFeatureState( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - featureName string, - overrideTraits ...*traits.TraitModel, -) *features.FeatureStateModel { - featureStates := getIdentityFeatureStatesMap(environment, identity, overrideTraits...) - - for _, featureState := range featureStates { - if featureState.Feature.Name == featureName { - return featureState + if shouldUpdate { + segmentFeatureContexts[featureKey] = featureContextWithSegmentName{ + featureContext: override, + segmentName: segmentContext.Name, + } + } + } } } - return nil + + return segments, segmentFeatureContexts } -func GetIdentitySegments( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - overrideTraits ...*traits.TraitModel, -) []*segments.SegmentModel { - var list []*segments.SegmentModel +func getFlagResults(ec *engine_eval.EngineEvaluationContext, segmentFeatureContexts map[string]featureContextWithSegmentName) map[string]*engine_eval.FlagResult { + flags := make(map[string]*engine_eval.FlagResult) - for _, s := range environment.Project.Segments { - if segments.EvaluateIdentityInSegment(identity, s, overrideTraits...) { - list = append(list, s) + // Get identity key if identity exists + var identityKey *string + if ec.Identity != nil { + identityKey = &ec.Identity.Key + } + + if ec.Features != nil { + for _, featureContext := range ec.Features { + // Check if we have a segment override for this feature + if segmentFeatureCtx, exists := segmentFeatureContexts[featureContext.FeatureKey]; exists { + // Use segment override + fc := segmentFeatureCtx.featureContext + reason := fmt.Sprintf("TARGETING_MATCH; segment=%s", segmentFeatureCtx.segmentName) + flags[featureContext.Name] = &engine_eval.FlagResult{ + Enabled: fc.Enabled, + FeatureKey: fc.FeatureKey, + Name: fc.Name, + Reason: &reason, + Value: fc.Value, + Metadata: fc.Metadata, + } + } else { + // Use default feature context + flagResult := getFlagResultFromFeatureContext(&featureContext, identityKey) + flags[featureContext.Name] = &flagResult + } } } - return list + return flags } -func getIdentityFeatureStatesMap( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - overrideTraits ...*traits.TraitModel, -) map[int]*features.FeatureStateModel { - featureStates := make(map[int]*features.FeatureStateModel) - for _, fs := range environment.FeatureStates { - featureStates[fs.Feature.ID] = fs +// GetEvaluationResult computes flags and matched segments. +func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { + // Process segments + segments, segmentFeatureContexts := getMatchingSegmentsAndOverrides(ec) + + // Get flag results + flags := getFlagResults(ec, segmentFeatureContexts) + + return engine_eval.EvaluationResult{ + Flags: flags, + Segments: segments, } +} - identitySegments := GetIdentitySegments(environment, identity, overrideTraits...) - for _, segment := range identitySegments { - for _, fs := range segment.FeatureStates { - existing_fs, exists := featureStates[fs.Feature.ID] - if exists && existing_fs.IsHigherSegmentPriority(fs) { - continue - } +// getFlagResultFromFeatureContext creates a FlagResult from a FeatureContext. +func getFlagResultFromFeatureContext(featureContext *engine_eval.FeatureContext, identityKey *string) engine_eval.FlagResult { + reason := "DEFAULT" + value := featureContext.Value + + // Handle multivariate features + if len(featureContext.Variants) > 0 && identityKey != nil && featureContext.Key != "" { + // Sort variants by priority (lower priority value = higher priority) + sortedVariants := getSortedVariantsByPriority(featureContext.Variants) - featureStates[fs.Feature.ID] = fs + // Calculate hash percentage for the identity and feature combination + objectIds := []string{featureContext.Key, *identityKey} + hashPercentage := utils.GetHashedPercentageForObjectIds(objectIds, 1) + + // Select variant based on weighted distribution + cumulativeWeight := 0.0 + for _, variant := range sortedVariants { + cumulativeWeight += variant.Weight + if hashPercentage <= cumulativeWeight { + value = variant.Value + reason = fmt.Sprintf("SPLIT; weight=%.0f", variant.Weight) + break + } } } - for _, fs := range identity.IdentityFeatures { - if _, ok := featureStates[fs.Feature.ID]; ok { - featureStates[fs.Feature.ID] = fs - } + flagResult := engine_eval.FlagResult{ + Enabled: featureContext.Enabled, + FeatureKey: featureContext.FeatureKey, + Name: featureContext.Name, + Value: value, + Reason: &reason, + Metadata: featureContext.Metadata, } - return featureStates + return flagResult +} + +// getSortedVariantsByPriority returns a copy of variants sorted by priority (lower priority number = higher priority). +// Variants without priority are treated as having the weakest priority (placed at the end). +func getSortedVariantsByPriority(variants []engine_eval.FeatureValue) []engine_eval.FeatureValue { + // Create a copy to avoid modifying the original slice + sortedVariants := make([]engine_eval.FeatureValue, len(variants)) + copy(sortedVariants, variants) + + // Sort by priority (lower number = higher priority) + sort.SliceStable(sortedVariants, func(i, j int) bool { + // Use big.Int Cmp: returns -1 if i < j (i has higher priority) + return sortedVariants[i].Priority.Cmp(&sortedVariants[j].Priority) < 0 + }) + + return sortedVariants } diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go new file mode 100644 index 00000000..21d5c25a --- /dev/null +++ b/flagengine/engine_eval/context.go @@ -0,0 +1,215 @@ +package engine_eval + +import ( + "encoding/json" + "fmt" + "math/big" +) + +// A context object containing the necessary information to evaluate Flagsmith feature flags. +type EngineEvaluationContext struct { + // Environment context required for evaluation. + Environment EnvironmentContext `json:"environment"` + // Features to be evaluated in the context. + Features map[string]FeatureContext `json:"features,omitempty"` + // Identity context used for identity-based evaluation. + Identity *IdentityContext `json:"identity,omitempty"` + // Segments applicable to the evaluation context. + Segments map[string]SegmentContext `json:"segments,omitempty"` +} + +// Environment context required for evaluation. +// +// Represents an environment context for feature flag evaluation. +type EnvironmentContext struct { + // An environment's unique identifier. + Key string `json:"key"` + // An environment's human-readable name. + Name string `json:"name"` +} + +// Represents a feature context for feature flag evaluation. +type FeatureContext struct { + // Indicates whether the feature is enabled in the environment. + Enabled bool `json:"enabled"` + // Unique feature identifier. + FeatureKey string `json:"feature_key"` + // Key used when selecting a value for a multivariate feature. Set to an internal identifier + // or a UUID, depending on Flagsmith implementation. + Key string `json:"key"` + // Feature name. + Name string `json:"name"` + // Priority of the feature context. Lower values indicate a higher priority when multiple + // contexts apply to the same feature. + Priority *float64 `json:"priority,omitempty"` + // A default environment value for the feature. If the feature is multivariate, this will be + // the control value. + Value any `json:"value"` + // An array of environment default values associated with the feature. Contains a single + // value for standard features, or multiple values for multivariate features. + Variants []FeatureValue `json:"variants,omitempty"` + // Metadata about the feature. + Metadata *FeatureMetadata `json:"metadata,omitempty"` +} + +// Represents a multivariate value for a feature flag. +type FeatureValue struct { + // The value of the feature. + Value any `json:"value"` + // The weight of the feature value variant, as a percentage number (i.e. 100.0). + Weight float64 `json:"weight"` + // Priority of the feature flag variant. Lower values indicate a higher priority when multiple variants apply to the same context key. + Priority big.Int `json:"priority,omitempty"` +} + +// FlexibleString is a type that can unmarshal from either string or number JSON values. +type FlexibleString string + +// UnmarshalJSON implements custom JSON unmarshaling for FlexibleString. +func (f *FlexibleString) UnmarshalJSON(data []byte) error { + // Try to unmarshal as a string first + var str string + if err := json.Unmarshal(data, &str); err == nil { + *f = FlexibleString(str) + return nil + } + + // Try to unmarshal as a number + var num json.Number + if err := json.Unmarshal(data, &num); err == nil { + *f = FlexibleString(num.String()) + return nil + } + + // Try to unmarshal as any type and convert to string + var val interface{} + if err := json.Unmarshal(data, &val); err == nil { + *f = FlexibleString(fmt.Sprintf("%v", val)) + return nil + } + + return fmt.Errorf("unable to unmarshal FlexibleString: invalid format") +} + +type IdentityContext struct { + // A unique identifier for an identity, used for segment and multivariate feature flag + // targeting, and displayed in the Flagsmith UI. + Identifier string `json:"identifier"` + // Key used when selecting a value for a multivariate feature, or for % split segmentation. + // Set to an internal identifier or a composite value based on the environment key and + // identifier, depending on Flagsmith implementation. + Key string `json:"key"` + // A map of traits associated with the identity, where the key is the trait name and the + // value is the trait value. + Traits map[string]any `json:"traits,omitempty"` +} + +// SegmentSource represents the source/origin of a segment. +type SegmentSource string + +const ( + // SegmentSourceAPI indicates the segment came from the Flagsmith API. + SegmentSourceAPI SegmentSource = "api" + // SegmentSourceIdentityOverride indicates the segment was created from identity overrides. + SegmentSourceIdentityOverride SegmentSource = "identity_override" +) + +// SegmentMetadata contains metadata information about a segment. +type SegmentMetadata struct { + SegmentID int `json:"segment_id,omitempty"` + // Source of the segment. + Source SegmentSource `json:"source,omitempty"` +} + +// FeatureMetadata contains metadata information about a feature. +type FeatureMetadata struct { + FeatureID int `json:"feature_id,omitempty"` +} + +// Represents a segment context for feature flag evaluation. +type SegmentContext struct { + // Key used for % split segmentation. + Key string `json:"key"` + // The name of the segment. + Name string `json:"name"` + // Metadata about the segment. + Metadata *SegmentMetadata `json:"metadata,omitempty"` + // Feature overrides for the segment. + Overrides []FeatureContext `json:"overrides,omitempty"` + // Rules that define the segment. + Rules []SegmentRule `json:"rules"` +} + +// Represents a rule within a segment for feature flag evaluation. +type SegmentRule struct { + // Conditions that must be met for the rule to apply. + Conditions []Condition `json:"conditions,omitempty"` + // Sub-rules nested within the segment rule. + Rules []SegmentRule `json:"rules,omitempty"` + // Segment rule type. Represents a logical quantifier for the conditions and sub-rules. + Type Type `json:"type"` +} + +// Represents a condition within a segment rule for feature flag evaluation. +// +// Represents an IN condition within a segment rule for feature flag evaluation. +type Condition struct { + // The operator to use for evaluating the condition. + Operator Operator `json:"operator"` + // A reference to the identity trait or value in the evaluation context. + Property string `json:"property"` + // The value to compare against the trait or context value. + // Can be a string or []string. + Value any `json:"value"` +} + +// The operator to use for evaluating the condition. +type Operator string + +const ( + Contains Operator = "CONTAINS" + Equal Operator = "EQUAL" + GreaterThan Operator = "GREATER_THAN" + GreaterThanInclusive Operator = "GREATER_THAN_INCLUSIVE" + In Operator = "IN" + IsNotSet Operator = "IS_NOT_SET" + IsSet Operator = "IS_SET" + LessThan Operator = "LESS_THAN" + LessThanInclusive Operator = "LESS_THAN_INCLUSIVE" + Modulo Operator = "MODULO" + NotContains Operator = "NOT_CONTAINS" + NotEqual Operator = "NOT_EQUAL" + PercentageSplit Operator = "PERCENTAGE_SPLIT" + Regex Operator = "REGEX" +) + +// Segment rule type. Represents a logical quantifier for the conditions and sub-rules. +type Type string + +const ( + All Type = "ALL" + Any Type = "ANY" + None Type = "NONE" +) + +// UnmarshalJSON implements custom JSON unmarshaling for IdentityContext. +func (ic *IdentityContext) UnmarshalJSON(data []byte) error { + // Use an alias to avoid recursion + type Alias IdentityContext + aux := struct { + Key FlexibleString `json:"key"` + *Alias + }{ + Alias: (*Alias)(ic), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + ic.Key = string(aux.Key) + return nil +} + +// ContextValue represents allowed types: nil, int, float64, bool, string. +type ContextValue interface{} diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go new file mode 100644 index 00000000..78d4d329 --- /dev/null +++ b/flagengine/engine_eval/evaluator.go @@ -0,0 +1,234 @@ +package engine_eval + +import ( + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" + "github.com/ohler55/ojg/jp" +) + +// IsContextInSegment determines if the given evaluation context matches the segment rules. +func IsContextInSegment(ec *EngineEvaluationContext, segmentContext *SegmentContext) bool { + if len(segmentContext.Rules) == 0 { + return false + } + for i := range segmentContext.Rules { + if !contextMatchesSegmentRule(ec, &segmentContext.Rules[i], segmentContext.Key) { + return false + } + } + return true +} + +// Returns true if conditions match according to the rule type. +func matchesConditionsByRuleType(ec *EngineEvaluationContext, conditions []Condition, ruleType Type, segmentKey string) bool { + for i := range conditions { + conditionMatches := contextMatchesCondition(ec, &conditions[i], segmentKey) + + switch ruleType { + case All: + if !conditionMatches { + return false // Short-circuit: ALL requires all conditions to match + } + case None: + if conditionMatches { + return false // Short-circuit: NONE requires no conditions to match + } + case Any: + if conditionMatches { + return true // Short-circuit: ANY requires at least one condition to match + } + default: + return false + } + } + + // If we reach here: ALL/NONE passed all checks, ANY found no matches + return ruleType != Any +} + +func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *SegmentRule, segmentKey string) bool { + if len(segmentRule.Conditions) > 0 { + if !matchesConditionsByRuleType(ec, segmentRule.Conditions, segmentRule.Type, segmentKey) { + return false + } + } + + for i := range segmentRule.Rules { + if !contextMatchesSegmentRule(ec, &segmentRule.Rules[i], segmentKey) { + return false + } + } + return true +} + +func matchPercentageSplit(ec *EngineEvaluationContext, segmentCondition *Condition, segmentKey string, contextValue ContextValue) bool { + var objectIds []string + + if contextValue != nil { + strValue := ToString(contextValue) + objectIds = []string{segmentKey, strValue} + } else if ec.Identity != nil { + objectIds = []string{segmentKey, ec.Identity.Key} + } else { + return false + } + + if segmentCondition.Value != nil { + if strValue, ok := segmentCondition.Value.(string); ok { + floatValue, err := strconv.ParseFloat(strValue, 64) + if err != nil { + return false + } + return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue + } + } + return false +} + +func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Condition, segmentKey string) bool { + var contextValue ContextValue + if segmentCondition.Property != "" { + contextValue = getContextValue(ec, segmentCondition.Property) + } + if segmentCondition.Operator == PercentageSplit { + return matchPercentageSplit(ec, segmentCondition, segmentKey, contextValue) + } + if segmentCondition.Operator == In { + return matchInOperator(segmentCondition, contextValue) + } + if segmentCondition.Operator == IsNotSet { + return contextValue == nil + } + if segmentCondition.Operator == IsSet { + return contextValue != nil + } + if contextValue != nil && segmentCondition.Value != nil { + if strValue, ok := segmentCondition.Value.(string); ok { + return parseAndMatch(segmentCondition.Operator, ToString(contextValue), strValue) + } + } + return false +} + +// matchInOperator handles the IN operator for segment conditions, supporting both StringArray and comma-separated strings. +func matchInOperator(segmentCondition *Condition, contextValue ContextValue) bool { + if contextValue == nil { + return false + } + + traitValue := ToString(contextValue) + + if segmentCondition.Value == nil { + return false + } + + // First try to use []string if available + if strArray, ok := segmentCondition.Value.([]string); ok { + return slices.Contains(strArray, traitValue) + } + + // Convert []interface{} to []string (happens during JSON unmarshaling) + if ifaceArray, ok := segmentCondition.Value.([]interface{}); ok { + for _, v := range ifaceArray { + if str, ok := v.(string); ok && str == traitValue { + return true + } + } + return false + } + + // Fall back to string - try JSON parsing first, then comma-separated + if strValue, ok := segmentCondition.Value.(string); ok { + // Try to parse as JSON array first + var jsonArray []string + if err := json.Unmarshal([]byte(strValue), &jsonArray); err == nil { + return slices.Contains(jsonArray, traitValue) + } + + // Fall back to comma-separated string + values := strings.Split(strValue, ",") + return slices.Contains(values, traitValue) + } + + return false +} + +func getContextValue(ec *EngineEvaluationContext, property string) ContextValue { + if strings.HasPrefix(property, "$.") { + value := getContextValueGetter(property)(ec) + // Only use JSONPath result if it's a primitive value (not an object/array/map) + if value != nil && isPrimitive(value) { + return value + } + // If JSONPath returned non-primitive or nil, fall back to checking traits by exact key name + } + + // Check traits by property name (handles both regular traits and invalid JSONPath strings) + if ec.Identity != nil && ec.Identity.Traits != nil { + value, exists := ec.Identity.Traits[property] + if exists { + return value + } + } + return nil +} + +// isPrimitive checks if a value is a primitive type (string, number, bool, nil) +// Objects, arrays, and maps are not considered primitive. +func isPrimitive(value any) bool { + if value == nil { + return true + } + switch value.(type) { + case string, bool, int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } +} + +// getContextValueGetter returns a function to retrieve a value from the evaluation context +// using either a JSONPath expression or returning nil if the property is not a valid JSONPath. +func getContextValueGetter(property string) func(ec *EngineEvaluationContext) any { + // First, try to parse the property as a JSONPath expression. + p, err := jp.ParseString(property) + if err == nil { + // If successful, create a getter for the JSONPath. + getter := func(evalCtx *EngineEvaluationContext) any { + results := p.Get(evalCtx) + if len(results) > 0 { + return results[0] + } + return nil + } + return getter + } + + // If JSONPath parsing fails, return a getter that always returns nil. + return func(ec *EngineEvaluationContext) any { + return nil + } +} + +func ToString(contextValue ContextValue) string { + if s, ok := contextValue.(string); ok { + return s + } + if b, ok := contextValue.(bool); ok { + return strconv.FormatBool(b) + } + if f, ok := contextValue.(float64); ok { + return strconv.FormatFloat(f, 'f', -1, 64) + } + if i, ok := contextValue.(int); ok { + return strconv.Itoa(i) + } + return fmt.Sprint(contextValue) +} diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go new file mode 100644 index 00000000..25f8ddb5 --- /dev/null +++ b/flagengine/engine_eval/evaluator_test.go @@ -0,0 +1,1000 @@ +package engine_eval_test + +import ( + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" +) + +const ( + traitKey1 = "email" + traitValue1 = "user@example.com" + + traitKey2 = "num_purchase" + traitValue2 = "12" + + traitKey3 = "date_joined" + traitValue3 = "2021-01-01" +) + +// Helper function to create a string value. +func stringValue(s string) string { + return s +} + +func boolValue(b bool) bool { + return b +} + +func doubleValue(d float64) float64 { + return d +} + +// Helper function to create evaluation context with traits. +func createEvaluationContext(traits map[string]any) *engine_eval.EngineEvaluationContext { + return &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: &engine_eval.IdentityContext{ + Identifier: "test-user", + Key: "test-env_test-user", + Traits: traits, + }, + } +} + +// Helper function to create segment context. +func createSegmentContext(key, name string, rules []engine_eval.SegmentRule) *engine_eval.SegmentContext { + // Convert key to int for SegmentID, defaulting to 0 if invalid + segmentID := 0 + if id, err := strconv.Atoi(key); err == nil { + segmentID = id + } + + return &engine_eval.SegmentContext{ + Key: key, + Name: name, + Metadata: &engine_eval.SegmentMetadata{ + SegmentID: segmentID, + Source: engine_eval.SegmentSourceAPI, + }, + Rules: rules, + } +} + +func TestIsContextInSegment(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + segmentContext *engine_eval.SegmentContext + evalContext *engine_eval.EngineEvaluationContext + expected bool + }{ + { + name: "empty segment rules returns false", + segmentContext: createSegmentContext("1", "empty_segment", []engine_eval.SegmentRule{}), + evalContext: createEvaluationContext(nil), + expected: false, + }, + { + name: "single condition matches", + segmentContext: createSegmentContext("2", "single_condition", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), + }), + expected: true, + }, + { + name: "single condition does not match", + segmentContext: createSegmentContext("3", "single_condition_no_match", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]any{ + traitKey1: stringValue("different@example.com"), + }), + expected: false, + }, + { + name: "multiple conditions ALL - all match", + segmentContext: createSegmentContext("4", "multiple_conditions_all", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: traitValue2, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + }), + expected: true, + }, + { + name: "multiple conditions ALL - one does not match", + segmentContext: createSegmentContext("5", "multiple_conditions_all_fail", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: traitValue2, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue("different_value"), + }), + expected: false, + }, + { + name: "multiple conditions ANY - one matches", + segmentContext: createSegmentContext("6", "multiple_conditions_any", []engine_eval.SegmentRule{ + { + Type: engine_eval.Any, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: traitValue2, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue("different_value"), + }), + expected: true, + }, + { + name: "nested rules", + segmentContext: createSegmentContext("7", "nested_rules", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Rules: []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: traitValue2, + }, + }, + }, + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey3, + Value: traitValue3, + }, + }, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + traitKey3: stringValue(traitValue3), + }), + expected: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result := engine_eval.IsContextInSegment(c.evalContext, c.segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestContextMatchesCondition(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + operator engine_eval.Operator + property string + conditionValue string + traitValue interface{} + expected bool + }{ + // String comparisons + {"equal strings match", engine_eval.Equal, traitKey1, "test", "test", true}, + {"equal strings don't match", engine_eval.Equal, traitKey1, "test", "different", false}, + {"not equal strings", engine_eval.NotEqual, traitKey1, "test", "different", true}, + {"not equal same strings", engine_eval.NotEqual, traitKey1, "test", "test", false}, + + // Numeric comparisons + {"greater than int", engine_eval.GreaterThan, traitKey2, "5", "10", true}, + {"greater than int false", engine_eval.GreaterThan, traitKey2, "10", "5", false}, + {"greater than equal", engine_eval.GreaterThan, traitKey2, "10", "10", false}, + {"greater than inclusive", engine_eval.GreaterThanInclusive, traitKey2, "10", "10", true}, + {"less than int", engine_eval.LessThan, traitKey2, "10", "5", true}, + {"less than int false", engine_eval.LessThan, traitKey2, "5", "10", false}, + {"less than inclusive", engine_eval.LessThanInclusive, traitKey2, "10", "10", true}, + + // Float comparisons + {"greater than float", engine_eval.GreaterThan, traitKey2, "5.5", "10.1", true}, + {"less than float", engine_eval.LessThan, traitKey2, "10.1", "5.5", true}, + + // Boolean comparisons + {"equal bool true", engine_eval.Equal, traitKey1, "true", "true", true}, + {"equal bool false", engine_eval.Equal, traitKey1, "false", "false", true}, + {"not equal bool", engine_eval.NotEqual, traitKey1, "true", "false", true}, + + // String operations + {"contains", engine_eval.Contains, traitKey1, "test", "testing", true}, + {"contains false", engine_eval.Contains, traitKey1, "xyz", "testing", false}, + {"not contains", engine_eval.NotContains, traitKey1, "xyz", "testing", true}, + {"not contains false", engine_eval.NotContains, traitKey1, "test", "testing", false}, + + // IN operator + {"in list first", engine_eval.In, traitKey1, "a,b,c", "a", true}, + {"in list middle", engine_eval.In, traitKey1, "a,b,c", "b", true}, + {"in list last", engine_eval.In, traitKey1, "a,b,c", "c", true}, + {"not in list", engine_eval.In, traitKey1, "a,b,c", "d", false}, + {"in single item", engine_eval.In, traitKey1, "test", "test", true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: c.operator, + Property: c.property, + Value: c.conditionValue, + } + + var traitValue any + switch v := c.traitValue.(type) { + case string: + traitValue = stringValue(v) + case bool: + traitValue = boolValue(v) + case float64: + traitValue = doubleValue(v) + default: + traitValue = stringValue(fmt.Sprint(v)) + } + + evalContext := createEvaluationContext(map[string]any{ + c.property: traitValue, + }) + + // We need to access the internal function, so we'll test via IsContextInSegment + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestContextMatchesConditionInOperatorStringArray(t *testing.T) { + traitKey1 := "trait1" + + cases := []struct { + name string + stringArray []string + traitValue string + expected bool + }{ + {"in string array first", []string{"a", "b", "c"}, "a", true}, + {"in string array middle", []string{"a", "b", "c"}, "b", true}, + {"in string array last", []string{"a", "b", "c"}, "c", true}, + {"not in string array", []string{"a", "b", "c"}, "d", false}, + {"in single item array", []string{"test"}, "test", true}, + {"empty string array", []string{}, "test", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: engine_eval.In, + Property: traitKey1, + Value: c.stringArray, + } + + traitValuePtr := stringValue(c.traitValue) + + evalContext := createEvaluationContext(map[string]any{ + traitKey1: traitValuePtr, + }) + + // Test via IsContextInSegment + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestContextMatchesConditionIsSetAndIsNotSet(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + operator engine_eval.Operator + property string + hasProperty bool + expectedResult bool + }{ + {"IsSet with property", engine_eval.IsSet, "foo", true, true}, + {"IsSet without property", engine_eval.IsSet, "foo", false, false}, + {"IsNotSet with property", engine_eval.IsNotSet, "foo", true, false}, + {"IsNotSet without property", engine_eval.IsNotSet, "foo", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: c.operator, + Property: c.property, + } + + var traits map[string]any + if c.hasProperty { + traits = map[string]any{ + c.property: stringValue("some_value"), + } + } + + evalContext := createEvaluationContext(traits) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expectedResult, result) + }) + } +} + +func TestContextMatchesConditionPercentageSplit(t *testing.T) { + cases := []struct { + name string + segmentSplitValue string + identityHashedPercentage float64 + expectedResult bool + }{ + {"10% split, 1% hash - should match", "10", 1.0, true}, + {"100% split, 50% hash - should match", "100", 50.0, true}, + {"0% split, 1% hash - should not match", "0", 1.0, false}, + {"10% split, 20% hash - should not match", "10", 20.0, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: engine_eval.PercentageSplit, + Property: "", + Value: c.segmentSplitValue, + } + + evalContext := createEvaluationContext(nil) + + // Mock the hashing function + utils.MockSetHashedPercentageForObjectIds(func(_ []string, _ int) float64 { + return c.identityHashedPercentage + }) + defer utils.ResetMocks() + + segmentContext := createSegmentContext("test-segment", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expectedResult, result) + }) + } +} + +func TestGetContextValueIntegration(t *testing.T) { + t.Parallel() + + // Test getContextValue indirectly through IsContextInSegment + // This tests that the function works correctly in the context it's used + + t.Run("simple trait lookup works", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]any{ + "email": stringValue("test@example.com"), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "email", + Value: "test@example.com", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("JSONPath identity identifier works", func(t *testing.T) { + evalContext := createEvaluationContext(nil) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "$.identity.identifier", + Value: "test-user", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("JSONPath environment key works", func(t *testing.T) { + evalContext := createEvaluationContext(nil) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "$.environment.key", + Value: "test-env", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) +} + +func TestToStringIntegration(t *testing.T) { + t.Parallel() + + // Test ToString indirectly through IsContextInSegment + // This tests that the function works correctly in the context it's used + + t.Run("string values work correctly", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]any{ + "test_prop": stringValue("test_string"), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "test_prop", + Value: "test_string", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("boolean values work correctly", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]any{ + "test_prop": boolValue(true), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "test_prop", + Value: "true", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("numeric values work correctly", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]any{ + "test_prop": doubleValue(123.45), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "test_prop", + Value: "123.45", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) +} + +func TestSemverComparisons(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + operator engine_eval.Operator + traitValue string + conditionValue string + expected bool + }{ + // Equal + {"semver equal match", engine_eval.Equal, "1.2.3", "1.2.3:semver", true}, + {"semver equal no match", engine_eval.Equal, "1.2.4", "1.2.3:semver", false}, + {"semver equal invalid trait", engine_eval.Equal, "not_a_semver", "1.2.3:semver", false}, + + // Not Equal + {"semver not equal same", engine_eval.NotEqual, "1.0.0", "1.0.0:semver", false}, + {"semver not equal different", engine_eval.NotEqual, "1.0.1", "1.0.0:semver", true}, + + // Greater Than + {"semver greater than true", engine_eval.GreaterThan, "1.0.1", "1.0.0:semver", true}, + {"semver greater than false", engine_eval.GreaterThan, "1.0.1", "1.1.0:semver", false}, + {"semver greater than equal", engine_eval.GreaterThan, "1.0.1", "1.0.1:semver", false}, + {"semver greater than with prerelease", engine_eval.GreaterThan, "1.2.4", "1.2.3-pre.2+build.4:semver", true}, + + // Less Than + {"semver less than false", engine_eval.LessThan, "1.0.1", "1.0.0:semver", false}, + {"semver less than true", engine_eval.LessThan, "1.0.1", "1.1.0:semver", true}, + {"semver less than equal", engine_eval.LessThan, "1.0.1", "1.0.1:semver", false}, + + // Greater Than Inclusive + {"semver gte true", engine_eval.GreaterThanInclusive, "1.0.1", "1.0.0:semver", true}, + {"semver gte false", engine_eval.GreaterThanInclusive, "1.0.1", "1.2.0:semver", false}, + {"semver gte equal", engine_eval.GreaterThanInclusive, "1.0.1", "1.0.1:semver", true}, + + // Less Than Inclusive + {"semver lte true", engine_eval.LessThanInclusive, "1.0.0", "1.0.1:semver", true}, + {"semver lte equal", engine_eval.LessThanInclusive, "1.0.0", "1.0.0:semver", true}, + {"semver lte false", engine_eval.LessThanInclusive, "1.0.1", "1.0.0:semver", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: c.operator, + Property: "version", + Value: c.conditionValue, + } + + evalContext := createEvaluationContext(map[string]any{ + "version": stringValue(c.traitValue), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestComplexSegmentRules(t *testing.T) { + t.Parallel() + + t.Run("conditions and nested rules", func(t *testing.T) { + // Test a segment with both conditions and nested rules + segmentContext := createSegmentContext("complex", "complex_segment", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + }, + Rules: []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: traitValue2, + }, + }, + }, + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey3, + Value: traitValue3, + }, + }, + }, + }, + }, + }) + + // Should match when all conditions are met + evalContext := createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + traitKey3: stringValue(traitValue3), + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + + // Should not match when one condition fails + evalContextPartial := createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + // Missing traitKey3 + }) + + result = engine_eval.IsContextInSegment(evalContextPartial, segmentContext) + assert.False(t, result) + }) + + t.Run("NONE rule type", func(t *testing.T) { + segmentContext := createSegmentContext("none_rule", "none_segment", []engine_eval.SegmentRule{ + { + Type: engine_eval.None, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: traitValue1, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: traitValue2, + }, + }, + }, + }) + + // Should match when no conditions are met (NONE rule) + evalContext := createEvaluationContext(map[string]any{ + traitKey1: stringValue("different1"), + traitKey2: stringValue("different2"), + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + + // Should not match when any condition is met + evalContextWithMatch := createEvaluationContext(map[string]any{ + traitKey1: stringValue(traitValue1), // This matches + traitKey2: stringValue("different2"), + }) + + result = engine_eval.IsContextInSegment(evalContextWithMatch, segmentContext) + assert.False(t, result) + }) +} + +func TestEdgeCases(t *testing.T) { + t.Parallel() + + t.Run("no identity context", func(t *testing.T) { + evalContext := &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: nil, // No identity + } + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "some_trait", + Value: "value", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.False(t, result) + }) + + t.Run("empty traits map", func(t *testing.T) { + evalContext := &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: &engine_eval.IdentityContext{ + Identifier: "test-user", + Key: "test-env_test-user", + Traits: nil, // No traits + }, + } + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.IsNotSet, + Property: "missing_trait", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) // IsNotSet should return true for missing trait + }) + + t.Run("percentage split without identity", func(t *testing.T) { + evalContext := &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: nil, + } + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.PercentageSplit, + Property: "", + Value: "50", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.False(t, result) // Should fail without identity + }) +} + +func TestRegexOperator(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + traitValue string + conditionValue string + expected bool + }{ + {"simple match", "foo", "[a-z]+", true}, + {"no match", "FOO", "[a-z]+", false}, + {"email match", "test@example.com", `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, true}, + {"invalid regex", "test", "[", false}, + {"empty values", "", "", true}, + {"number match", "123", `^\d+$`, true}, + {"number no match", "abc", `^\d+$`, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + evalContext := createEvaluationContext(map[string]any{ + "test_trait": stringValue(c.traitValue), + }) + + segmentContext := createSegmentContext("regex_test", "regex_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Regex, + Property: "test_trait", + Value: c.conditionValue, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestModuloOperator(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + traitValue string + conditionValue string + expected bool + }{ + {"simple modulo match", "2", "2|0", true}, + {"simple modulo no match", "1", "2|0", false}, + {"float modulo match", "1.1", "2.1|1.1", true}, + {"float modulo no match", "3", "2|0", false}, + {"large number match", "35.0", "4|3", true}, + {"large number no match", "34.2", "4|3", false}, + {"invalid trait value", "foo", "4|3", false}, + {"invalid condition format", "1", "invalid", false}, + {"invalid divisor", "1", "abc|3", false}, + {"invalid remainder", "1", "4|abc", false}, + {"missing separator", "1", "43", false}, + {"too many parts", "1", "4|3|2", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + evalContext := createEvaluationContext(map[string]any{ + "test_trait": stringValue(c.traitValue), + }) + + segmentContext := createSegmentContext("modulo_test", "modulo_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Modulo, + Property: "test_trait", + Value: c.conditionValue, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestMatchWithRegexOperator(t *testing.T) { + t.Parallel() + + evalContext := createEvaluationContext(map[string]any{ + "email": stringValue("test@example.com"), + }) + + segmentContext := createSegmentContext("regex_test", "regex_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Regex, + Property: "email", + Value: `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) +} + +func TestMatchWithModuloOperator(t *testing.T) { + t.Parallel() + + evalContext := createEvaluationContext(map[string]any{ + "user_id": stringValue("35"), + }) + + segmentContext := createSegmentContext("modulo_test", "modulo_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Modulo, + Property: "user_id", + Value: "4|3", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) +} diff --git a/flagengine/engine_eval/generic_evaluator.go b/flagengine/engine_eval/generic_evaluator.go new file mode 100644 index 00000000..dcf15aa4 --- /dev/null +++ b/flagengine/engine_eval/generic_evaluator.go @@ -0,0 +1,206 @@ +package engine_eval + +import ( + "math" + "regexp" + "strconv" + "strings" + + "github.com/blang/semver/v4" +) + +// Comparable defines types that can be compared using standard operators. +// This includes all numeric types, strings, and booleans. +type Comparable interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | + ~string | ~bool +} + +// Ordered defines types that support ordering operations (>, <, >=, <=). +// Note that bool is excluded as it doesn't support ordering. +type Ordered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | + ~string +} + +// Generic comparison functions - one per operator + +// evaluateEqualGeneric implements the EQUAL operator for comparable types. +func evaluateEqualGeneric[T Comparable](v1, v2 T) bool { + return v1 == v2 +} + +// evaluateNotEqualGeneric implements the NOT_EQUAL operator for comparable types. +func evaluateNotEqualGeneric[T Comparable](v1, v2 T) bool { + return v1 != v2 +} + +// evaluateGreaterThanGeneric implements the GREATER_THAN operator for ordered types. +func evaluateGreaterThanGeneric[T Ordered](v1, v2 T) bool { + return v1 > v2 +} + +// evaluateLessThanGeneric implements the LESS_THAN operator for ordered types. +func evaluateLessThanGeneric[T Ordered](v1, v2 T) bool { + return v1 < v2 +} + +// evaluateGreaterThanInclusiveGeneric implements the GREATER_THAN_INCLUSIVE operator for ordered types. +func evaluateGreaterThanInclusiveGeneric[T Ordered](v1, v2 T) bool { + return v1 >= v2 +} + +// evaluateLessThanInclusiveGeneric implements the LESS_THAN_INCLUSIVE operator for ordered types. +func evaluateLessThanInclusiveGeneric[T Ordered](v1, v2 T) bool { + return v1 <= v2 +} + +// evaluateContainsGeneric implements the CONTAINS operator for strings. +func evaluateContainsGeneric(v1, v2 string) bool { + return strings.Contains(v1, v2) +} + +// evaluateNotContainsGeneric implements the NOT_CONTAINS operator for strings. +func evaluateNotContainsGeneric(v1, v2 string) bool { + return !strings.Contains(v1, v2) +} + +// dispatchOperator dispatches the operator to the appropriate generic function. +func dispatchOperator[T Ordered](operator Operator, v1, v2 T) bool { + switch operator { + case Equal: + return evaluateEqualGeneric(v1, v2) + case NotEqual: + return evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + return evaluateGreaterThanGeneric(v1, v2) + case LessThan: + return evaluateLessThanGeneric(v1, v2) + case GreaterThanInclusive: + return evaluateGreaterThanInclusiveGeneric(v1, v2) + case LessThanInclusive: + return evaluateLessThanInclusiveGeneric(v1, v2) + } + return false +} + +// dispatchComparableOperator dispatches equality operators for comparable types (including bool). +func dispatchComparableOperator[T Comparable](operator Operator, v1, v2 T) bool { + switch operator { + case Equal: + return evaluateEqualGeneric(v1, v2) + case NotEqual: + return evaluateNotEqualGeneric(v1, v2) + } + return false +} + +// parseAndMatch attempts to parse string values into specific types and compare them using generics. +func parseAndMatch(operator Operator, traitValue, conditionValue string) bool { + // Handle special operators first + switch operator { + case Modulo: + return evaluateModuloGeneric(traitValue, conditionValue) + case Regex: + return evaluateRegexGeneric(traitValue, conditionValue) + case Contains: + return evaluateContainsGeneric(traitValue, conditionValue) + case NotContains: + return evaluateNotContainsGeneric(traitValue, conditionValue) + } + + // Handle semver comparison + if strings.HasSuffix(conditionValue, ":semver") { + conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) + if err != nil { + return false + } + return evaluateSemverGeneric(operator, traitValue, conditionVersion) + } + + // Try boolean parsing + if b1, e1 := strconv.ParseBool(traitValue); e1 == nil { + if b2, e2 := strconv.ParseBool(conditionValue); e2 == nil { + return dispatchComparableOperator(operator, b1, b2) + } + } + + // Try integer parsing + if i1, e1 := strconv.ParseInt(traitValue, 10, 64); e1 == nil { + if i2, e2 := strconv.ParseInt(conditionValue, 10, 64); e2 == nil { + return dispatchOperator(operator, i1, i2) + } + } + + // Try float parsing + if f1, e1 := strconv.ParseFloat(traitValue, 64); e1 == nil { + if f2, e2 := strconv.ParseFloat(conditionValue, 64); e2 == nil { + return dispatchOperator(operator, f1, f2) + } + } + + // Fall back to string comparison + return dispatchOperator(operator, traitValue, conditionValue) +} + +// evaluateRegexGeneric performs regex matching on trait values. +func evaluateRegexGeneric(traitValue, conditionValue string) bool { + match, err := regexp.Match(conditionValue, []byte(traitValue)) + if err != nil { + return false + } + return match +} + +// evaluateModuloGeneric performs modulo operation matching on trait values. +func evaluateModuloGeneric(traitValue, conditionValue string) bool { + values := strings.Split(conditionValue, "|") + if len(values) != 2 { + return false + } + + divisor, err := strconv.ParseFloat(values[0], 64) + if err != nil { + return false + } + + remainder, err := strconv.ParseFloat(values[1], 64) + if err != nil { + return false + } + + traitValueFloat, err := strconv.ParseFloat(traitValue, 64) + if err != nil { + return false + } + + return math.Mod(traitValueFloat, divisor) == remainder +} + +// evaluateSemverGeneric handles semantic version comparisons. +func evaluateSemverGeneric(operator Operator, traitValue string, conditionVersion semver.Version) bool { + traitVersion, err := semver.Make(traitValue) + if err != nil { + return false + } + + switch operator { + case Equal: + return traitVersion.EQ(conditionVersion) + case NotEqual: + return traitVersion.NE(conditionVersion) + case GreaterThan: + return traitVersion.GT(conditionVersion) + case LessThan: + return traitVersion.LT(conditionVersion) + case GreaterThanInclusive: + return traitVersion.GE(conditionVersion) + case LessThanInclusive: + return traitVersion.LTE(conditionVersion) + } + return false +} diff --git a/flagengine/engine_eval/generic_evaluator_test.go b/flagengine/engine_eval/generic_evaluator_test.go new file mode 100644 index 00000000..71948945 --- /dev/null +++ b/flagengine/engine_eval/generic_evaluator_test.go @@ -0,0 +1,156 @@ +package engine_eval + +import ( + "testing" +) + +func TestMatchGeneric(t *testing.T) { + tests := []struct { + name string + operator Operator + v1 any + v2 any + expected bool + }{ + // Boolean tests + {"bool equal true", Equal, true, true, true}, + {"bool equal false", Equal, false, false, true}, + {"bool not equal", Equal, true, false, false}, + {"bool not equal operator", NotEqual, true, false, true}, + + // Integer tests + {"int equal", Equal, int64(42), int64(42), true}, + {"int not equal", Equal, int64(42), int64(43), false}, + {"int greater than", GreaterThan, int64(43), int64(42), true}, + {"int less than", LessThan, int64(42), int64(43), true}, + {"int greater than equal", GreaterThanInclusive, int64(42), int64(42), true}, + {"int less than equal", LessThanInclusive, int64(42), int64(42), true}, + + // Float tests + {"float equal", Equal, 42.5, 42.5, true}, + {"float not equal", Equal, 42.5, 42.6, false}, + {"float greater than", GreaterThan, 42.6, 42.5, true}, + {"float less than", LessThan, 42.5, 42.6, true}, + + // String tests + {"string equal", Equal, "hello", "hello", true}, + {"string not equal", Equal, "hello", "world", false}, + {"string greater than", GreaterThan, "world", "hello", true}, + {"string less than", LessThan, "hello", "world", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result bool + switch v1 := tt.v1.(type) { + case bool: + if v2, ok := tt.v2.(bool); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + } + } + case int64: + if v2, ok := tt.v2.(int64); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + result = evaluateGreaterThanGeneric(v1, v2) + case LessThan: + result = evaluateLessThanGeneric(v1, v2) + case GreaterThanInclusive: + result = evaluateGreaterThanInclusiveGeneric(v1, v2) + case LessThanInclusive: + result = evaluateLessThanInclusiveGeneric(v1, v2) + } + } + case float64: + if v2, ok := tt.v2.(float64); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + result = evaluateGreaterThanGeneric(v1, v2) + case LessThan: + result = evaluateLessThanGeneric(v1, v2) + } + } + case string: + if v2, ok := tt.v2.(string); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + result = evaluateGreaterThanGeneric(v1, v2) + case LessThan: + result = evaluateLessThanGeneric(v1, v2) + } + } + } + + if result != tt.expected { + t.Errorf("evaluateGeneric(%v, %v, %v) = %v, want %v", tt.operator, tt.v1, tt.v2, result, tt.expected) + } + }) + } +} + +func TestParseAndMatch(t *testing.T) { + tests := []struct { + name string + operator Operator + traitValue string + conditionValue string + expected bool + }{ + // Boolean parsing and comparison + {"parse bool equal true", Equal, "true", "true", true}, + {"parse bool equal false", Equal, "false", "false", true}, + {"parse bool not equal", Equal, "true", "false", false}, + {"parse bool not equal operator", NotEqual, "true", "false", true}, + + // Integer parsing and comparison + {"parse int equal", Equal, "42", "42", true}, + {"parse int not equal", Equal, "42", "43", false}, + {"parse int greater than", GreaterThan, "43", "42", true}, + {"parse int less than", LessThan, "42", "43", true}, + + // Float parsing and comparison + {"parse float equal", Equal, "42.5", "42.5", true}, + {"parse float not equal", Equal, "42.5", "42.6", false}, + {"parse float greater than", GreaterThan, "42.6", "42.5", true}, + + // String comparison (when parsing fails) + {"string equal", Equal, "hello", "hello", true}, + {"string not equal", Equal, "hello", "world", false}, + {"string contains", Contains, "hello world", "world", true}, + {"string not contains", NotContains, "hello world", "xyz", true}, + + // Mixed type parsing (should fall back to string) + {"mixed types", Equal, "42", "hello", false}, + {"mixed bool and int", Equal, "true", "1", true}, // Both parse as bool: true == true + + // Semver comparison + {"semver equal", Equal, "1.2.3", "1.2.3:semver", true}, + {"semver greater than", GreaterThan, "1.2.4", "1.2.3:semver", true}, + {"semver less than", LessThan, "1.2.2", "1.2.3:semver", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseAndMatch(tt.operator, tt.traitValue, tt.conditionValue) + if result != tt.expected { + t.Errorf("parseAndMatch(%v, %q, %q) = %v, want %v", tt.operator, tt.traitValue, tt.conditionValue, result, tt.expected) + } + }) + } +} diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go new file mode 100644 index 00000000..9f61e718 --- /dev/null +++ b/flagengine/engine_eval/mappers.go @@ -0,0 +1,352 @@ +package engine_eval + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "sort" + "strconv" + "strings" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/trait" +) + +// MapEnvironmentDocumentToEvaluationContext maps an environment document model +// to the higher-level EngineEvaluationContext representation used for evaluation. +func MapEnvironmentDocumentToEvaluationContext(env *environments.EnvironmentModel) EngineEvaluationContext { + ctx := EngineEvaluationContext{} + + // Environment + // map environment -> EnvironmentContext + ctx.Environment = EnvironmentContext{ + Key: env.APIKey, + Name: env.Name, + } + + // Features (environment defaults) + if len(env.FeatureStates) > 0 { + ctx.Features = make(map[string]FeatureContext, len(env.FeatureStates)) + for _, fs := range env.FeatureStates { + fc := mapFeatureStateToFeatureContext(fs) + ctx.Features[fc.Name] = fc + } + } + + // Segments (from project) + if env.Project != nil && len(env.Project.Segments) > 0 { + ctx.Segments = make(map[string]SegmentContext, len(env.Project.Segments)) + for _, s := range env.Project.Segments { + sc := mapSegmentToSegmentContext(s) + ctx.Segments[sc.Key] = sc + } + } + + // Identity overrides (mapped to segments) + if len(env.IdentityOverrides) > 0 { + identitySegments := mapIdentityOverridesToSegments(env.IdentityOverrides) + if ctx.Segments == nil { + ctx.Segments = make(map[string]SegmentContext) + } + for key, segment := range identitySegments { + ctx.Segments[key] = segment + } + } + + return ctx +} + +// mapMultivariateFeatureStateValuesToVariants converts multivariate feature state values to FeatureValue variants. +func mapMultivariateFeatureStateValuesToVariants(multivariateValues []*features.MultivariateFeatureStateValueModel) []FeatureValue { + if len(multivariateValues) == 0 { + return nil + } + + variants := make([]FeatureValue, 0, len(multivariateValues)) + for _, mv := range multivariateValues { + variants = append(variants, FeatureValue{ + Value: mv.MultivariateFeatureOption.Value, + Weight: mv.PercentageAllocation, + Priority: mv.Priority(), + }) + } + return variants +} + +func mapFeatureStateToFeatureContext(fs *features.FeatureStateModel) FeatureContext { + var key string + if fs.DjangoID != 0 { + key = strconv.Itoa(fs.DjangoID) + } else { + key = fs.FeatureStateUUID + } + + fc := FeatureContext{ + Enabled: fs.Enabled, + FeatureKey: strconv.Itoa(fs.Feature.ID), + Key: key, + Name: fs.Feature.Name, + Metadata: &FeatureMetadata{ + FeatureID: fs.Feature.ID, + }, + } + + // Value + if fs.RawValue != nil { + fc.Value = fs.RawValue + } + + // Variants + fc.Variants = mapMultivariateFeatureStateValuesToVariants(fs.MultivariateFeatureStateValues) + + // Priority (if present via segment override) + if fs.FeatureSegment != nil { + p := float64(fs.FeatureSegment.Priority) + fc.Priority = &p + } + + return fc +} + +func mapSegmentToSegmentContext(s *segments.SegmentModel) SegmentContext { + sc := SegmentContext{ + Key: strconv.Itoa(s.ID), + Name: s.Name, + Metadata: &SegmentMetadata{ + SegmentID: s.ID, + Source: SegmentSourceAPI, + }, + Rules: make([]SegmentRule, 0, len(s.Rules)), + } + + // Overrides + for _, fs := range s.FeatureStates { + sc.Overrides = append(sc.Overrides, mapFeatureStateToFeatureContext(fs)) + } + + // Rules + for _, r := range s.Rules { + sc.Rules = append(sc.Rules, mapSegmentRuleToRule(r)) + } + + return sc +} + +func mapSegmentRuleToRule(r *segments.SegmentRuleModel) SegmentRule { + er := SegmentRule{Type: mapRuleType(r.Type)} + // Conditions + for _, c := range r.Conditions { + er.Conditions = append(er.Conditions, Condition{ + Operator: Operator(c.Operator), + Property: c.Property, + Value: c.Value, + }) + } + // Nested rules + for _, sr := range r.Rules { + er.Rules = append(er.Rules, mapSegmentRuleToRule(sr)) + } + return er +} + +func mapRuleType(t segments.RuleType) Type { + switch t { + case segments.All: + return All + case segments.Any: + return Any + default: + return None + } +} + +// overridesKey represents a unique set of feature overrides for grouping identities. +type overridesKey struct { + featureName string + enabled bool + featureValue string +} + +// overridesKeyList is a sortable slice of overridesKey. +type overridesKeyList []overridesKey + +func (o overridesKeyList) Len() int { return len(o) } +func (o overridesKeyList) Swap(i, j int) { o[i], o[j] = o[j], o[i] } +func (o overridesKeyList) Less(i, j int) bool { return o[i].featureName < o[j].featureName } + +// generateHash creates a hash from the overrides key for use as segment key. +func generateHash(overrides overridesKeyList) string { + // Sort to ensure consistent hash for same set of overrides + sort.Sort(overrides) + + // Create a string representation of the overrides + var hashInput string + for _, override := range overrides { + hashInput += fmt.Sprintf("%s:%t:%s;", override.featureName, override.enabled, override.featureValue) + } + + // Generate SHA256 hash + hash := sha256.Sum256([]byte(hashInput)) + return hex.EncodeToString(hash[:])[:16] // Use first 16 characters for shorter key +} + +// This groups identities by their common feature overrides and creates segments for each group. +func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityModel) map[string]SegmentContext { + // Map from overrides key to list of identifiers + featuresToIdentifiers := make(map[string][]string) + overridesKeyToList := make(map[string]overridesKeyList) + featureNameToID := make(map[string]int) + + for _, identityOverride := range identityOverrides { + if len(identityOverride.IdentityFeatures) == 0 { + continue + } + + // Create overrides key from sorted features + var overrides overridesKeyList + for _, featureState := range identityOverride.IdentityFeatures { + featureValue := "" + if featureState.RawValue != nil { + featureValue = fmt.Sprint(featureState.RawValue) + } + + // Store feature name to ID mapping for later lookup + featureNameToID[featureState.Feature.Name] = featureState.Feature.ID + + overrides = append(overrides, overridesKey{ + featureName: featureState.Feature.Name, + enabled: featureState.Enabled, + featureValue: featureValue, + }) + } + + // Generate hash for this set of overrides + overridesHash := generateHash(overrides) + + // Group identifiers by their overrides + featuresToIdentifiers[overridesHash] = append(featuresToIdentifiers[overridesHash], identityOverride.Identifier) + overridesKeyToList[overridesHash] = overrides + } + + // Create segment contexts for each unique set of overrides + segmentContexts := make(map[string]SegmentContext) + + for overridesHash, identifiers := range featuresToIdentifiers { + overrides := overridesKeyToList[overridesHash] + + // Create segment context + sc := SegmentContext{ + Key: "", // Identity override segments never use % Split operator + Name: "identity_overrides", + Metadata: &SegmentMetadata{ + Source: SegmentSourceIdentityOverride, + }, + Rules: []SegmentRule{ + { + Type: All, + Conditions: []Condition{ + { + Operator: "IN", + Property: "$.identity.identifier", + Value: strings.Join(identifiers, ","), + }, + }, + }, + }, + } + + // Create overrides for each feature + for _, override := range overrides { + priority := math.Inf(-1) // Strongest possible priority + featureID := featureNameToID[override.featureName] + featureOverride := FeatureContext{ + Key: "", // Identity overrides never carry multivariate options + FeatureKey: strconv.Itoa(featureID), + Name: override.featureName, + Enabled: override.enabled, + Priority: &priority, + Value: override.featureValue, + Metadata: &FeatureMetadata{ + FeatureID: featureID, + }, + } + + sc.Overrides = append(sc.Overrides, featureOverride) + } + + segmentContexts[overridesHash] = sc + } + + return segmentContexts +} + +type Trait = trait.Trait + +// MapContextAndIdentityDataToContext maps context and identity data to create an evaluation context +// with identity information. This function takes an existing context and enriches it with identity +// data including identifier and traits. +func MapContextAndIdentityDataToContext( + context EngineEvaluationContext, + identifier string, + traits []*trait.Trait, +) EngineEvaluationContext { + // Create a copy of the context + newContext := context + + // Create traits map for the identity + identityTraits := make(map[string]any) + + for _, trait := range traits { + if trait == nil { + continue + } + + identityTraits[trait.TraitKey] = trait.TraitValue + } + + // Create the identity context + environmentKey := newContext.Environment.Key + identity := IdentityContext{ + Identifier: identifier, + Key: fmt.Sprintf("%s_%s", environmentKey, identifier), + Traits: identityTraits, + } + + // Set the identity in the context + newContext.Identity = &identity + + return newContext +} + +// MapEvaluationResultSegmentsToSegmentModels converts evaluation result segments +// to segments.SegmentModel with only ID and Name populated. +// Only segments with API source are included (identity overrides are filtered out). +func MapEvaluationResultSegmentsToSegmentModels( + result *EvaluationResult, +) []*segments.SegmentModel { + if len(result.Segments) == 0 { + return nil + } + + segmentModels := make([]*segments.SegmentModel, 0, len(result.Segments)) + + for _, segmentResult := range result.Segments { + // Only include segments from API source (filter out identity overrides) + if segmentResult.Metadata == nil || segmentResult.Metadata.Source != SegmentSourceAPI { + continue + } + + segmentModel := &segments.SegmentModel{ + ID: segmentResult.Metadata.SegmentID, + Name: segmentResult.Name, + } + + segmentModels = append(segmentModels, segmentModel) + } + + return segmentModels +} diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go new file mode 100644 index 00000000..5d6bfe9a --- /dev/null +++ b/flagengine/engine_eval/mappers_test.go @@ -0,0 +1,624 @@ +package engine_eval + +import ( + "math" + "testing" + "time" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/projects" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" +) + +func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { + // Create test data + env := &environments.EnvironmentModel{ + ID: 1, + Name: "Test Environment", + APIKey: "test-api-key", + Project: &projects.ProjectModel{ + ID: 1, + Name: "Test Project", + Segments: []*segments.SegmentModel{ + { + ID: 1, + Name: "test-segment", + Rules: []*segments.SegmentRuleModel{ + { + Type: segments.All, + Conditions: []*segments.SegmentConditionModel{ + { + Operator: "EQUAL", + Property: "test_property", + Value: "test_value", + }, + }, + }, + }, + FeatureStates: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "segment-override-feature", + }, + RawValue: "segment-value", + }, + }, + }, + }, + }, + FeatureStates: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "test-feature", + }, + RawValue: "test-value", + FeatureStateUUID: "test-uuid", + DjangoID: 123, + }, + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 2, + Name: "disabled-feature", + }, + RawValue: nil, + }, + }, + UpdatedAt: time.Now(), + } + + // Test the mapping function + result := MapEnvironmentDocumentToEvaluationContext(env) + + // Test Environment mapping + if result.Environment.Key != "test-api-key" { + t.Errorf("Expected Environment.Key to be 'test-api-key', got %v", result.Environment.Key) + } + if result.Environment.Name != "Test Environment" { + t.Errorf("Expected Environment.Name to be 'Test Environment', got %v", result.Environment.Name) + } + + // Test Features mapping + if len(result.Features) != 2 { + t.Errorf("Expected 2 features, got %d", len(result.Features)) + } + + // Test first feature + testFeature, exists := result.Features["test-feature"] + if !exists { + t.Error("Expected 'test-feature' to exist in Features map") + } else { + if !testFeature.Enabled { + t.Error("Expected test-feature to be enabled") + } + if testFeature.FeatureKey != "1" { + t.Errorf("Expected FeatureKey to be '1' (feature ID), got %v", testFeature.FeatureKey) + } + if testFeature.Name != "test-feature" { + t.Errorf("Expected Name to be 'test-feature', got %v", testFeature.Name) + } + if testFeature.Key != "123" { + t.Errorf("Expected Key to be '123' (from DjangoID), got %v", testFeature.Key) + } + if testFeature.Value == nil { + t.Error("Expected Value to be set") + } else if valueStr, ok := testFeature.Value.(string); !ok || valueStr != "test-value" { + t.Errorf("Expected Value to be 'test-value', got %v", testFeature.Value) + } + } + + // Test second feature (disabled with nil value) + disabledFeature, exists := result.Features["disabled-feature"] + if !exists { + t.Error("Expected 'disabled-feature' to exist in Features map") + } else { + if disabledFeature.Enabled { + t.Error("Expected disabled-feature to be disabled") + } + if disabledFeature.Value != nil { + t.Errorf("Expected Value to be nil for disabled feature, got %v", disabledFeature.Value) + } + } + + // Test Segments mapping + if len(result.Segments) != 1 { + t.Errorf("Expected 1 segment, got %d", len(result.Segments)) + } + + testSegment, exists := result.Segments["1"] + if !exists { + t.Error("Expected segment with key '1' to exist in Segments map") + } else { + if testSegment.Name != "test-segment" { + t.Errorf("Expected segment name to be 'test-segment', got %v", testSegment.Name) + } + if testSegment.Key != "1" { + t.Errorf("Expected segment key to be '1', got %v", testSegment.Key) + } + + // Test segment rules + if len(testSegment.Rules) != 1 { + t.Errorf("Expected 1 rule in segment, got %d", len(testSegment.Rules)) + } else { + rule := testSegment.Rules[0] + if rule.Type != All { + t.Errorf("Expected rule type to be All, got %v", rule.Type) + } + if len(rule.Conditions) != 1 { + t.Errorf("Expected 1 condition in rule, got %d", len(rule.Conditions)) + } else { + condition := rule.Conditions[0] + if condition.Operator != "EQUAL" { + t.Errorf("Expected condition operator to be 'EQUAL', got %v", condition.Operator) + } + if condition.Property != "test_property" { + t.Errorf("Expected condition property to be 'test_property', got %v", condition.Property) + } + if condition.Value == nil { + t.Error("Expected condition value to be set") + } else if strValue, ok := condition.Value.(string); !ok || strValue != "test_value" { + t.Errorf("Expected condition value to be 'test_value', got %v", condition.Value) + } + } + } + + // Test segment overrides + if len(testSegment.Overrides) != 1 { + t.Errorf("Expected 1 override in segment, got %d", len(testSegment.Overrides)) + } else { + override := testSegment.Overrides[0] + if override.FeatureKey != "1" { + t.Errorf("Expected override feature key to be '1' (feature ID), got %v", override.FeatureKey) + } + if !override.Enabled { + t.Error("Expected segment override to be enabled") + } + if override.Value == nil { + t.Error("Expected override Value to be set") + } else if valueStr, ok := override.Value.(string); !ok || valueStr != "segment-value" { + t.Errorf("Expected override value to be 'segment-value', got %v", override.Value) + } + } + } +} + +func TestMapEnvironmentDocumentToEvaluationContextWithNilProject(t *testing.T) { + env := &environments.EnvironmentModel{ + ID: 1, + Name: "Test Env Without Project", + APIKey: "test-api-key", + Project: nil, + FeatureStates: []*features.FeatureStateModel{}, + UpdatedAt: time.Now(), + } + + result := MapEnvironmentDocumentToEvaluationContext(env) + + // Environment name should be preserved + if result.Environment.Name != "Test Env Without Project" { + t.Errorf("Expected Environment.Name to be 'Test Env Without Project', got %v", result.Environment.Name) + } + + // Should have no segments when project is nil + if len(result.Segments) != 0 { + t.Errorf("Expected 0 segments when project is nil, got %d", len(result.Segments)) + } +} + +func TestMapEnvironmentDocumentToEvaluationContextWithEmptyFeatureStates(t *testing.T) { + env := &environments.EnvironmentModel{ + ID: 1, + APIKey: "test-api-key", + Project: &projects.ProjectModel{ + ID: 1, + Name: "Test Project", + Segments: []*segments.SegmentModel{}, + }, + FeatureStates: []*features.FeatureStateModel{}, + UpdatedAt: time.Now(), + } + + result := MapEnvironmentDocumentToEvaluationContext(env) + + // Should have no features when FeatureStates is empty + if len(result.Features) != 0 { + t.Errorf("Expected 0 features when FeatureStates is empty, got %d", len(result.Features)) + } + + // Should have no segments when project segments is empty + if len(result.Segments) != 0 { + t.Errorf("Expected 0 segments when project segments is empty, got %d", len(result.Segments)) + } +} + +func TestMapEnvironmentDocumentToEvaluationContextWithIdentityOverrides(t *testing.T) { + env := &environments.EnvironmentModel{ + ID: 1, + APIKey: "test-api-key", + Project: &projects.ProjectModel{ + ID: 1, + Name: "Test Project", + Segments: []*segments.SegmentModel{}, + }, + FeatureStates: []*features.FeatureStateModel{}, + IdentityOverrides: []*identities.IdentityModel{ + { + Identifier: "user1", + EnvironmentAPIKey: "test-api-key", + CreatedDate: utils.ISOTime{Time: time.Now()}, + IdentityUUID: "uuid-1", + IdentityFeatures: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "feature_1", + }, + RawValue: "override_value_1", + }, + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 2, + Name: "feature_2", + }, + RawValue: "override_value_2", + }, + }, + }, + { + Identifier: "user2", + EnvironmentAPIKey: "test-api-key", + CreatedDate: utils.ISOTime{Time: time.Now()}, + IdentityUUID: "uuid-2", + IdentityFeatures: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "feature_1", + }, + RawValue: "override_value_1", + }, + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 2, + Name: "feature_2", + }, + RawValue: "override_value_2", + }, + }, + }, + { + Identifier: "user3", + EnvironmentAPIKey: "test-api-key", + CreatedDate: utils.ISOTime{Time: time.Now()}, + IdentityUUID: "uuid-3", + IdentityFeatures: []*features.FeatureStateModel{ + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 1, + Name: "feature_1", + }, + RawValue: "different_value", + }, + }, + }, + }, + UpdatedAt: time.Now(), + } + + result := MapEnvironmentDocumentToEvaluationContext(env) + + // Should have created segments from identity overrides + if len(result.Segments) != 2 { + t.Errorf("Expected 2 segments (one for user1+user2 with same overrides, one for user3), got %d", len(result.Segments)) + } + + // Check that segments have the correct structure + foundIdentitySegments := 0 + for _, segment := range result.Segments { + if segment.Name == "identity_overrides" { + foundIdentitySegments++ + + // Should have one rule of type All + if len(segment.Rules) != 1 { + t.Errorf("Expected 1 rule in identity override segment, got %d", len(segment.Rules)) + } else { + rule := segment.Rules[0] + if rule.Type != All { + t.Errorf("Expected rule type to be All, got %v", rule.Type) + } + + // Should have one condition for identity identifier + if len(rule.Conditions) != 1 { + t.Errorf("Expected 1 condition in rule, got %d", len(rule.Conditions)) + } else { + condition := rule.Conditions[0] + if condition.Operator != "IN" { + t.Errorf("Expected condition operator to be 'IN', got %v", condition.Operator) + } + if condition.Property != "$.identity.identifier" { + t.Errorf("Expected condition property to be '$.identity.identifier', got %v", condition.Property) + } + if condition.Value == nil { + t.Error("Expected condition value to be set") + } + } + } + + // Should have feature overrides + if len(segment.Overrides) == 0 { + t.Error("Expected identity override segment to have feature overrides") + } + + // Check override priorities are set to negative infinity + for _, override := range segment.Overrides { + if override.Priority == nil { + t.Error("Expected feature override to have priority set") + } else if *override.Priority != math.Inf(-1) { + t.Errorf("Expected priority to be negative infinity, got %v", *override.Priority) + } + } + } + } + + if foundIdentitySegments != 2 { + t.Errorf("Expected to find 2 identity override segments, found %d", foundIdentitySegments) + } +} + +func TestMapContextAndIdentityDataToContext(t *testing.T) { + // Create a base context + baseContext := EngineEvaluationContext{ + Environment: EnvironmentContext{ + Key: "test-env-key", + Name: "Test Environment", + }, + Features: map[string]FeatureContext{ + "test-feature": { + Enabled: true, + FeatureKey: "1", + Name: "test-feature", + }, + }, + } + + // Test with different trait value types + traitList := []*Trait{ + {TraitKey: "string_trait", TraitValue: "string_value"}, + {TraitKey: "int_trait", TraitValue: 42}, + {TraitKey: "float_trait", TraitValue: 3.14}, + {TraitKey: "bool_true_trait", TraitValue: true}, + {TraitKey: "bool_false_trait", TraitValue: false}, + {TraitKey: "string_number_trait", TraitValue: "99"}, + {TraitKey: "string_bool_trait", TraitValue: "true"}, + {TraitKey: "empty_trait", TraitValue: ""}, + } + + result := MapContextAndIdentityDataToContext(baseContext, "test-user", traitList) + + // Check that the original context is preserved + if result.Environment.Key != "test-env-key" { + t.Errorf("Expected environment key to be preserved, got %v", result.Environment.Key) + } + if result.Environment.Name != "Test Environment" { + t.Errorf("Expected environment name to be preserved, got %v", result.Environment.Name) + } + if len(result.Features) != 1 { + t.Errorf("Expected features to be preserved, got %d features", len(result.Features)) + } + + // Check identity context + if result.Identity == nil { + t.Fatal("Expected identity to be set") + } + + identity := result.Identity + if identity.Identifier != "test-user" { + t.Errorf("Expected identifier to be 'test-user', got %v", identity.Identifier) + } + if identity.Key != "test-env-key_test-user" { + t.Errorf("Expected key to be 'test-env-key_test-user', got %v", identity.Key) + } + + // Check traits + if identity.Traits == nil { + t.Fatal("Expected traits to be set") + } + + // Test string trait + if stringTrait, exists := identity.Traits["string_trait"]; !exists { + t.Error("Expected string_trait to exist") + } else if stringTrait != "string_value" { + t.Errorf("Expected string_trait to be 'string_value', got %v", stringTrait) + } + + // Test int trait + if intTrait, exists := identity.Traits["int_trait"]; !exists { + t.Error("Expected int_trait to exist") + } else if intTrait != 42 { + t.Errorf("Expected int_trait to be 42, got %v", intTrait) + } + + // Test float trait (float64 3.14) + if floatTrait, exists := identity.Traits["float_trait"]; !exists { + t.Error("Expected float_trait to exist") + } else if floatTrait != 3.14 { + t.Errorf("Expected float_trait to be 3.14, got %v", floatTrait) + } + + // Test bool true trait (bool true) + if boolTrueTrait, exists := identity.Traits["bool_true_trait"]; !exists { + t.Error("Expected bool_true_trait to exist") + } else if boolTrueTrait != true { + t.Errorf("Expected bool_true_trait to be true, got %v", boolTrueTrait) + } + + // Test bool false trait (bool false) + if boolFalseTrait, exists := identity.Traits["bool_false_trait"]; !exists { + t.Error("Expected bool_false_trait to exist") + } else if boolFalseTrait != false { + t.Errorf("Expected bool_false_trait to be false, got %v", boolFalseTrait) + } + + // Test string number trait (string "99" parsed as float64) + if stringNumberTrait, exists := identity.Traits["string_number_trait"]; !exists { + t.Error("Expected string_number_trait to exist") + } else if stringNumberTrait != "99" { + t.Errorf("Expected string_number_trait to be 99.0, got %v", stringNumberTrait) + } + + // Test string bool trait (string "true" parsed as bool) + if stringBoolTrait, exists := identity.Traits["string_bool_trait"]; !exists { + t.Error("Expected string_bool_trait to exist") + } else if stringBoolTrait != "true" { + t.Errorf("Expected string_bool_trait to be true, got %v", stringBoolTrait) + } + + // Test empty trait (should be included as empty string is a valid value) + if emptyTrait, exists := identity.Traits["empty_trait"]; !exists { + t.Error("Expected empty_trait to be included") + } else if emptyStr, ok := emptyTrait.(string); !ok || emptyStr != "" { + t.Errorf("Expected empty_trait to be empty string, got %v", emptyTrait) + } +} + +func TestMapContextAndIdentityDataToContextWithNilTraits(t *testing.T) { + baseContext := EngineEvaluationContext{ + Environment: EnvironmentContext{ + Key: "test-env-key", + Name: "Test Environment", + }, + } + + result := MapContextAndIdentityDataToContext(baseContext, "test-user", nil) + + // Check identity context + if result.Identity == nil { + t.Fatal("Expected identity to be set") + } + + identity := result.Identity + if identity.Identifier != "test-user" { + t.Errorf("Expected identifier to be 'test-user', got %v", identity.Identifier) + } + if identity.Key != "test-env-key_test-user" { + t.Errorf("Expected key to be 'test-env-key_test-user', got %v", identity.Key) + } + + // Should have empty traits map when nil traits passed + if len(identity.Traits) != 0 { + t.Errorf("Expected empty traits map, got %d traits", len(identity.Traits)) + } +} + +func TestMapEvaluationResultSegmentsToSegmentModels(t *testing.T) { + // Create a test evaluation result with segments + result := EvaluationResult{ + Segments: []SegmentResult{ + { + Key: "1", + Name: "test-segment", + Metadata: &SegmentMetadata{ + SegmentID: 1, + Source: SegmentSourceAPI, + }, + }, + { + Key: "42", + Name: "another-segment", + Metadata: &SegmentMetadata{ + SegmentID: 42, + Source: SegmentSourceAPI, + }, + }, + { + Key: "", + Name: "identity-override-segment", + Metadata: &SegmentMetadata{ + SegmentID: 0, + Source: SegmentSourceIdentityOverride, + }, + }, + }, + } + + // Test the mapper + segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) + + // Assertions - should only include API segments (2), not identity overrides + if len(segmentModels) != 2 { + t.Errorf("Expected 2 segment models, got %d", len(segmentModels)) + } + + // First segment + segment1 := segmentModels[0] + if segment1.ID != 1 { + t.Errorf("Expected segment ID to be 1, got %d", segment1.ID) + } + + if segment1.Name != "test-segment" { + t.Errorf("Expected segment name to be 'test-segment', got %s", segment1.Name) + } + + // Rules and FeatureStates should be nil/empty since we only populate ID and Name + if segment1.Rules != nil { + t.Errorf("Expected Rules to be nil, got %v", segment1.Rules) + } + + if segment1.FeatureStates != nil { + t.Errorf("Expected FeatureStates to be nil, got %v", segment1.FeatureStates) + } + + // Second segment + segment2 := segmentModels[1] + if segment2.ID != 42 { + t.Errorf("Expected segment ID to be 42, got %d", segment2.ID) + } + + if segment2.Name != "another-segment" { + t.Errorf("Expected segment name to be 'another-segment', got %s", segment2.Name) + } +} + +func TestMapEvaluationResultSegmentsToSegmentModelsEmpty(t *testing.T) { + // Test with empty segments + result := EvaluationResult{ + Segments: []SegmentResult{}, + } + + segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) + + if segmentModels != nil { + t.Errorf("Expected nil for empty segments, got %v", segmentModels) + } +} + +func TestMapEvaluationResultSegmentsToSegmentModelsInvalidKey(t *testing.T) { + // Test with segment result that has no metadata (should be filtered out) + result := EvaluationResult{ + Segments: []SegmentResult{ + { + Key: "invalid-key", + Name: "segment-without-metadata", + }, + }, + } + + segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) + + // Segments without metadata should be filtered out + if len(segmentModels) != 0 { + t.Errorf("Expected 0 segment models (no metadata), got %d", len(segmentModels)) + } +} diff --git a/flagengine/engine_eval/result.go b/flagengine/engine_eval/result.go new file mode 100644 index 00000000..cd463ad3 --- /dev/null +++ b/flagengine/engine_eval/result.go @@ -0,0 +1,34 @@ +package engine_eval + +// Evaluation result object containing flag evaluation results, and +// segments used in the evaluation. +type EvaluationResult struct { + // Feature flags evaluated for the context, mapped by feature names. + Flags map[string]*FlagResult `json:"flags"` + // List of segments which the provided context belongs to. + Segments []SegmentResult `json:"segments"` +} + +type FlagResult struct { + // Indicates if the feature flag is enabled. + Enabled bool `json:"enabled"` + // Unique feature identifier. + FeatureKey string `json:"feature_key"` + // Feature name. + Name string `json:"name"` + // Reason for the feature flag evaluation. + Reason *string `json:"reason,omitempty"` + // Feature flag value. + Value any `json:"value,omitempty"` + // Metadata about the feature. + Metadata *FeatureMetadata `json:"metadata,omitempty"` +} + +type SegmentResult struct { + // Unique segment identifier. + Key string `json:"key"` + // Segment name. + Name string `json:"name"` + // Metadata about the segment. + Metadata *SegmentMetadata `json:"metadata,omitempty"` +} diff --git a/flagengine/engine_test.go b/flagengine/engine_test.go deleted file mode 100644 index 99ab40e6..00000000 --- a/flagengine/engine_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package flagengine_test - -import ( - "testing" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils/fixtures" - "github.com/stretchr/testify/assert" -) - -func TestIdentityGetFeatureStateWithoutAnyOverride(t *testing.T) { - t.Parallel() - feature1, _, _, env, identity := fixtures.GetFixtures() - - featureState := flagengine.GetIdentityFeatureState(env, identity, feature1.Name) - assert.Equal(t, feature1, featureState.Feature) -} - -func TestIdentityGetAllFeatureStatesNoSegments(t *testing.T) { - t.Parallel() - _, _, _, env, identity := fixtures.GetFixtures() - - overriddenFeature := &features.FeatureModel{ID: 3, Name: "overridden_feature", Type: "STANDARD"} - - // set the state of the feature to false in the environment configuration - env.FeatureStates = append(env.FeatureStates, &features.FeatureStateModel{ - DjangoID: 3, Feature: overriddenFeature, Enabled: false, - }) - - // but true for the identity - identity.IdentityFeatures = []*features.FeatureStateModel{ - {DjangoID: 4, Feature: overriddenFeature, Enabled: true}, - } - - allFeatureStates := flagengine.GetIdentityFeatureStates(env, identity) - assert.Len(t, allFeatureStates, 3) - for _, fs := range allFeatureStates { - envFeatureState := getEnvironmentFeatureStateForFeature(env, fs.Feature) - - var expected bool - if fs.Feature == overriddenFeature { - expected = true - } else { - expected = envFeatureState.Enabled - } - assert.Equal(t, expected, fs.Enabled) - } -} - -func TestGetIdentityFeatureStatesHidesDisabledFlagsIfEnabled(t *testing.T) { - t.Parallel() - _, _, _, env, identity := fixtures.GetFixtures() - env.Project.HideDisabledFlags = true - - featureStates := flagengine.GetIdentityFeatureStates(env, identity) - - for _, fs := range featureStates { - assert.True(t, fs.Enabled) - } -} - -func TestIdentityGetAllFeatureStatesSegmentsOnly(t *testing.T) { - t.Parallel() - _, _, segment, env, _ := fixtures.GetFixtures() - traitMatchingSegment := fixtures.TraitMatchingSegment(fixtures.SegmentCondition()) - identityInSegment := fixtures.IdentityInSegment(traitMatchingSegment, env) - - overriddenFeature := &features.FeatureModel{ - ID: 3, - Name: "overridden_feature", - Type: "STANDARD", - } - - env.FeatureStates = append(env.FeatureStates, &features.FeatureStateModel{ - DjangoID: 3, - Feature: overriddenFeature, - Enabled: false, - }) - - segment.FeatureStates = append(segment.FeatureStates, &features.FeatureStateModel{ - DjangoID: 4, - Feature: overriddenFeature, - Enabled: true, - }) - - allFeatureStates := flagengine.GetIdentityFeatureStates(env, identityInSegment) - - assert.Len(t, allFeatureStates, 3) - - for _, fs := range allFeatureStates { - envFeatureState := getEnvironmentFeatureStateForFeature(env, fs.Feature) - expected := envFeatureState.Enabled - if fs.Feature == overriddenFeature { - expected = true - } - assert.Equal(t, expected, fs.Enabled) - } -} - -func TestIdentityGetAllFeatureStatesWithTraits(t *testing.T) { - feature1, _, segment, env, identity := fixtures.GetFixtures() - - envWithSegmentOverride := fixtures.EnvironmentWithSegmentOverride(env, fixtures.SegmentOverrideFs(segment, feature1), segment) - - traitModels := []*traits.TraitModel{ - {TraitKey: fixtures.SegmentConditionProperty, TraitValue: fixtures.SegmentConditionStringValue}, - } - - allFeatureStates := flagengine.GetIdentityFeatureStates(envWithSegmentOverride, identity, traitModels...) - found := false - for _, fs := range allFeatureStates { - if fs.RawValue == "segment_override" { - found = true - break - } - } - assert.True(t, found, "expected to find feature state with segment_override value") -} - -func TestEnvironmentGetAllFeatureStates(t *testing.T) { - t.Parallel() - - _, _, _, env, _ := fixtures.GetFixtures() - featureStates := flagengine.GetEnvironmentFeatureStates(env) - - assert.Equal(t, env.FeatureStates, featureStates) -} - -func TestEnvironmentGetFeatureStatesHidesDisabledFlagsIfEnabled(t *testing.T) { - t.Parallel() - - _, _, _, env, _ := fixtures.GetFixtures() - env.Project.HideDisabledFlags = true - featureStates := flagengine.GetEnvironmentFeatureStates(env) - - assert.NotEqual(t, env.FeatureStates, featureStates) - for _, fs := range featureStates { - assert.True(t, fs.Enabled) - } -} - -func TestEnvironmentGetFeatureState(t *testing.T) { - t.Parallel() - - feature1, _, _, env, _ := fixtures.GetFixtures() - fs := flagengine.GetEnvironmentFeatureState(env, feature1.Name) - - assert.Equal(t, feature1, fs.Feature) -} - -func TestEnvironmentGetFeatureStateFeatureNotFound(t *testing.T) { - t.Parallel() - - _, _, _, env, _ := fixtures.GetFixtures() - fs := flagengine.GetEnvironmentFeatureState(env, "not_a_feature_name") - assert.Nil(t, fs) -} - -func getEnvironmentFeatureStateForFeature(env *environments.EnvironmentModel, feature *features.FeatureModel) *features.FeatureStateModel { - for _, fs := range env.FeatureStates { - if fs.Feature == feature { - return fs - } - } - return nil -} diff --git a/flagengine/environments/models.go b/flagengine/environments/models.go index 24f30642..980f6a4c 100644 --- a/flagengine/environments/models.go +++ b/flagengine/environments/models.go @@ -3,13 +3,14 @@ package environments import ( "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/projects" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/projects" ) type EnvironmentModel struct { ID int `json:"id"` + Name string `json:"name"` APIKey string `json:"api_key"` Project *projects.ProjectModel `json:"project"` FeatureStates []*features.FeatureStateModel `json:"feature_states"` diff --git a/flagengine/features/models.go b/flagengine/features/models.go index d7699b12..1392ad30 100644 --- a/flagengine/features/models.go +++ b/flagengine/features/models.go @@ -2,10 +2,12 @@ package features import ( "encoding/json" + "math/big" "sort" "strconv" + "strings" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) type FeatureModel struct { @@ -71,6 +73,21 @@ func (mfsv *MultivariateFeatureStateValueModel) Key() string { } return mfsv.MVFSValueUUID } +func (mfsv *MultivariateFeatureStateValueModel) Priority() big.Int { + if mfsv.ID != nil { + return *big.NewInt(int64(*mfsv.ID)) + } + // When ID is not set, convert the UUID to a big integer for priority + if mfsv.MVFSValueUUID != "" { + // Remove hyphens from UUID and parse as hexadecimal + hexStr := strings.ReplaceAll(mfsv.MVFSValueUUID, "-", "") + if bigInt, ok := new(big.Int).SetString(hexStr, 16); ok { + return *bigInt + } + } + // Return max int64 as default (weakest priority - no priority set) + return *big.NewInt(9223372036854775807) +} func (fs *FeatureStateModel) Value(identityID string) interface{} { if identityID != "" && len(fs.MultivariateFeatureStateValues) > 0 { diff --git a/flagengine/features/models_test.go b/flagengine/features/models_test.go index 56f250d9..33e2ce78 100644 --- a/flagengine/features/models_test.go +++ b/flagengine/features/models_test.go @@ -1,9 +1,10 @@ package features_test import ( + "math/big" "testing" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" "github.com/stretchr/testify/assert" ) @@ -34,3 +35,69 @@ func TestFeatureStateIsHigherSegmentPriority(t *testing.T) { assert.True(t, featureState1.IsHigherSegmentPriority(&featureState2)) assert.False(t, featureState2.IsHigherSegmentPriority(&featureState1)) } + +func TestMultivariateFeatureStateValueModelPriorityWithID(t *testing.T) { + t.Parallel() + id := 42 + mfsv := features.MultivariateFeatureStateValueModel{ + ID: &id, + } + + priority := mfsv.Priority() + expected := *big.NewInt(42) + + assert.Equal(t, expected, priority, "Priority should equal the ID value") +} + +func TestMultivariateFeatureStateValueModelPriorityWithUUID(t *testing.T) { + t.Parallel() + uuid := "550e8400-e29b-41d4-a716-446655440000" + mfsv := features.MultivariateFeatureStateValueModel{ + MVFSValueUUID: uuid, + } + + priority := mfsv.Priority() + + // Parse the expected value from the UUID (without hyphens, as hex) + expectedBigInt := new(big.Int) + expectedBigInt.SetString("550e8400e29b41d4a716446655440000", 16) + + assert.Equal(t, *expectedBigInt, priority, "Priority should equal the UUID parsed as big int") +} + +func TestMultivariateFeatureStateValueModelPriorityIDTakesPrecedenceOverUUID(t *testing.T) { + t.Parallel() + id := 100 + uuid := "550e8400-e29b-41d4-a716-446655440000" + mfsv := features.MultivariateFeatureStateValueModel{ + ID: &id, + MVFSValueUUID: uuid, + } + + priority := mfsv.Priority() + expected := *big.NewInt(100) + + assert.Equal(t, expected, priority, "Priority should use ID when both ID and UUID are present") +} + +func TestMultivariateFeatureStateValueModelPriorityDefaultsToMaxInt64(t *testing.T) { + t.Parallel() + mfsv := features.MultivariateFeatureStateValueModel{} + + priority := mfsv.Priority() + expected := *big.NewInt(9223372036854775807) // math.MaxInt64 + + assert.Equal(t, expected, priority, "Priority should default to max int64 when neither ID nor UUID is set") +} + +func TestMultivariateFeatureStateValueModelPriorityWithInvalidUUID(t *testing.T) { + t.Parallel() + mfsv := features.MultivariateFeatureStateValueModel{ + MVFSValueUUID: "not-a-valid-uuid", + } + + priority := mfsv.Priority() + expected := *big.NewInt(9223372036854775807) // Should default to max int64 + + assert.Equal(t, expected, priority, "Priority should default to max int64 when UUID is invalid") +} diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 330702a8..11a503c2 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -3,61 +3,80 @@ package flagengine_test import ( "encoding/json" "os" - "sort" - "strconv" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tailscale/hujson" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" ) -const TestData = "./engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json" +const TestDataDir = "./engine-test-data/test_cases" func TestEngine(t *testing.T) { t.Parallel() - var testData struct { - Environment environments.EnvironmentModel `json:"environment"` - TestCases []struct { - Identity identities.IdentityModel `json:"identity"` - Response struct { - Traits []traits.TraitModel `json:"traits"` - Flags []features.FeatureStateModel `json:"flags"` - } `json:"response"` - } `json:"identities_and_responses"` - } - testSpec, err := os.ReadFile(TestData) + // Read all test case files from the test_cases directory (both .json and .jsonc) + jsonFiles, err := filepath.Glob(filepath.Join(TestDataDir, "*.json")) require.NoError(t, err) - require.NotEmpty(t, testSpec) - err = json.Unmarshal(testSpec, &testData) + jsoncFiles, err := filepath.Glob(filepath.Join(TestDataDir, "*.jsonc")) require.NoError(t, err) - for i, c := range testData.TestCases { - t.Run(strconv.Itoa(i)+":"+c.Identity.CompositeKey(), func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) - actual := flagengine.GetIdentityFeatureStates(&testData.Environment, &c.Identity) - expected := c.Response.Flags - - sort.Slice(actual, func(i, j int) bool { - return actual[i].Feature.Name < actual[j].Feature.Name - }) - sort.Slice(expected, func(i, j int) bool { - return expected[i].Feature.Name < expected[j].Feature.Name - }) - - require.Len(actual, len(expected)) - for i := range expected { - id := strconv.Itoa(c.Identity.DjangoID) - assert.Equal(expected[i].Value(id), actual[i].Value(id)) - assert.Equal(expected[i].Enabled, actual[i].Enabled) + files := append(jsonFiles, jsoncFiles...) + require.NotEmpty(t, files, "No test case files found in %s", TestDataDir) + + for _, testFile := range files { + testFile := testFile // Capture range variable + + // Get test name by removing extension + testName := filepath.Base(testFile) + testName = strings.TrimSuffix(testName, filepath.Ext(testName)) + + t.Run(testName, func(t *testing.T) { + t.Parallel() + + // Read the test case file + testSpec, err := os.ReadFile(testFile) + require.NoError(t, err) + require.NotEmpty(t, testSpec) + + // Standardise .jsonc files to standard JSON + if strings.HasSuffix(testFile, ".jsonc") { + ast, err := hujson.Parse(testSpec) + require.NoError(t, err) + ast.Standardize() + testSpec = ast.Pack() + } + + // Parse the test case + var testCase struct { + Context engine_eval.EngineEvaluationContext `json:"context"` + Result engine_eval.EvaluationResult `json:"result"` + } + + err = json.Unmarshal(testSpec, &testCase) + require.NoError(t, err) + + // Run the evaluation + actual := flagengine.GetEvaluationResult(&testCase.Context) + expected := testCase.Result + + // Compare the results + assert.Equal(t, expected.Flags, actual.Flags, "Flags should match") + + // Compare segments + if len(expected.Segments) > 0 { + require.Len(t, actual.Segments, len(expected.Segments), "Segment count should match") + for i, expectedSeg := range expected.Segments { + assert.Equal(t, expectedSeg.Key, actual.Segments[i].Key, "Segment key should match") + assert.Equal(t, expectedSeg.Name, actual.Segments[i].Name, "Segment name should match") + assert.Equal(t, expectedSeg.Metadata, actual.Segments[i].Metadata, "Segment metadata should match") + } } }) } diff --git a/flagengine/identities/models.go b/flagengine/identities/models.go index 96e16ba2..7cbc1d49 100644 --- a/flagengine/identities/models.go +++ b/flagengine/identities/models.go @@ -1,9 +1,9 @@ package identities import ( - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) type IdentityModel struct { diff --git a/flagengine/projects/models.go b/flagengine/projects/models.go index 57acd461..303385c0 100644 --- a/flagengine/projects/models.go +++ b/flagengine/projects/models.go @@ -1,8 +1,8 @@ package projects import ( - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/organisations" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/organisations" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" ) type ProjectModel struct { diff --git a/flagengine/segments/const.go b/flagengine/segments/const.go index 7f6de949..8c96eb26 100644 --- a/flagengine/segments/const.go +++ b/flagengine/segments/const.go @@ -15,7 +15,7 @@ const ( Contains ConditionOperator = "CONTAINS" GreaterThanInclusive ConditionOperator = "GREATER_THAN_INCLUSIVE" NotContains ConditionOperator = "NOT_CONTAINS" - NotEqual ConditionOperator = "NOT EQUAL" + NotEqual ConditionOperator = "NOT_EQUAL" Regex ConditionOperator = "REGEX" PercentageSplit ConditionOperator = "PERCENTAGE_SPLIT" IsSet ConditionOperator = "IS_SET" diff --git a/flagengine/segments/evaluator.go b/flagengine/segments/evaluator.go deleted file mode 100644 index f0f22857..00000000 --- a/flagengine/segments/evaluator.go +++ /dev/null @@ -1,211 +0,0 @@ -package segments - -import ( - "strconv" - "strings" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" - "github.com/blang/semver/v4" - "golang.org/x/exp/slices" -) - -func EvaluateIdentityInSegment( - identity *identities.IdentityModel, - segment *SegmentModel, - overrideTraits ...*traits.TraitModel, -) bool { - if len(segment.Rules) == 0 { - return false - } - - traits := identity.IdentityTraits - if len(overrideTraits) > 0 { - traits = overrideTraits - } - - identityHashKey := identity.CompositeKey() - if identity.DjangoID != 0 { - identityHashKey = strconv.Itoa(identity.DjangoID) - } - for _, rule := range segment.Rules { - if !traitsMatchSegmentRule(traits, rule, segment.ID, identityHashKey) { - return false - } - } - - return true -} - -func traitsMatchSegmentRule( - identityTraits []*traits.TraitModel, - rule *SegmentRuleModel, - segmentID int, - identityID string, -) bool { - conditions := make([]bool, len(rule.Conditions)) - for i, c := range rule.Conditions { - conditions[i] = traitsMatchSegmentCondition(identityTraits, c, segmentID, identityID) - } - matchesConditions := rule.MatchingFunction()(conditions) || len(rule.Conditions) == 0 - - rules := make([]bool, len(rule.Rules)) - for i, r := range rule.Rules { - rules[i] = traitsMatchSegmentRule(identityTraits, r, segmentID, identityID) - } - - return matchesConditions && utils.All(rules) -} - -func traitsMatchSegmentCondition( - identityTraits []*traits.TraitModel, - condition *SegmentConditionModel, - segmentID int, - identityID string, -) bool { - if condition.Operator == PercentageSplit { - floatValue, _ := strconv.ParseFloat(condition.Value, 64) - return utils.GetHashedPercentageForObjectIds([]string{strconv.Itoa(segmentID), identityID}, 1) <= floatValue - } - var matchedTraitValue *string - for _, trait := range identityTraits { - if trait.TraitKey == condition.Property { - matchedTraitValue = &trait.TraitValue - } - } - - if condition.Operator == IsNotSet { - return matchedTraitValue == nil - } - if condition.Operator == IsSet { - return matchedTraitValue != nil - } - - if matchedTraitValue != nil { - return condition.MatchesTraitValue(*matchedTraitValue) - } - return false -} - -func match(c ConditionOperator, traitValue, conditionValue string) bool { - b1, e1 := strconv.ParseBool(traitValue) - b2, e2 := strconv.ParseBool(conditionValue) - if e1 == nil && e2 == nil { - return matchBool(c, b1, b2) - } - - i1, e1 := strconv.ParseInt(traitValue, 10, 64) - i2, e2 := strconv.ParseInt(conditionValue, 10, 64) - if e1 == nil && e2 == nil { - return matchInt(c, i1, i2) - } - - f1, e1 := strconv.ParseFloat(traitValue, 64) - f2, e2 := strconv.ParseFloat(conditionValue, 64) - if e1 == nil && e2 == nil { - return matchFloat(c, f1, f2) - } - if strings.HasSuffix(conditionValue, ":semver") { - conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) - if err != nil { - return false - } - return matchSemver(c, traitValue, conditionVersion) - } - - return matchString(c, traitValue, conditionValue) -} - -func matchSemver(c ConditionOperator, traitValue string, conditionVersion semver.Version) bool { - traitVersion, err := semver.Make(traitValue) - if err != nil { - return false - } - switch c { - case Equal: - return traitVersion.EQ(conditionVersion) - case GreaterThan: - return traitVersion.GT(conditionVersion) - case LessThan: - return traitVersion.LT(conditionVersion) - case LessThanInclusive: - return traitVersion.LTE(conditionVersion) - case GreaterThanInclusive: - return traitVersion.GE(conditionVersion) - case NotEqual: - return traitVersion.NE(conditionVersion) - } - return false -} - -func matchBool(c ConditionOperator, v1, v2 bool) bool { - var i1, i2 int64 - if v1 { - i1 = 1 - } - if v2 { - i2 = 1 - } - return matchInt(c, i1, i2) -} - -func matchInt(c ConditionOperator, v1, v2 int64) bool { - switch c { - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchFloat(c ConditionOperator, v1, v2 float64) bool { - switch c { - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchString(c ConditionOperator, v1, v2 string) bool { - switch c { - case Contains: - return strings.Contains(v1, v2) - case NotContains: - return !strings.Contains(v1, v2) - case In: - return slices.Contains(strings.Split(v2, ","), v1) - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} diff --git a/flagengine/segments/evaluator_test.go b/flagengine/segments/evaluator_test.go deleted file mode 100644 index 9b18dc28..00000000 --- a/flagengine/segments/evaluator_test.go +++ /dev/null @@ -1,503 +0,0 @@ -package segments_test - -import ( - "fmt" - "strconv" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils/fixtures" -) - -const ( - trait_key_1 = "email" - trait_value_1 = "user@example.com" - - trait_key_2 = "num_purchase" - trait_value_2 = "12" - - trait_key_3 = "date_joined" - trait_value_3 = "2021-01-01" -) - -var ( - empty_segment = &segments.SegmentModel{ID: 1, Name: "empty_segment"} - segment_single_condition = &segments.SegmentModel{ - ID: 2, - Name: "segment_one_condition", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - }, - }, - }, - } - segment_multiple_conditions_all = &segments.SegmentModel{ - ID: 3, - Name: "segment_multiple_conditions_all", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - }, - } - segment_multiple_conditions_any = &segments.SegmentModel{ - ID: 4, - Name: "segment_multiple_conditions_all", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.Any, - Conditions: []*segments.SegmentConditionModel{{ - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - }, - } - segment_nested_rules = &segments.SegmentModel{ - ID: 5, - Name: "segment_nested_rules_all", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_3, - Value: trait_value_3, - }, - }, - }, - }, - }, - }, - } - segment_conditions_and_nested_rules = &segments.SegmentModel{ - ID: 6, - Name: "segment_multiple_conditions_all_and_nested_rules", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - }, - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_3, - Value: trait_value_3, - }, - }, - }, - }, - }, - }, - } -) - -func TestIdentityInSegment(t *testing.T) { - t.Parallel() - - cases := []struct { - segment *segments.SegmentModel - identityTraits []*traits.TraitModel - expected bool - }{ - {empty_segment, nil, false}, - {segment_single_condition, nil, false}, - { - segment_single_condition, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, - true, - }, - {segment_multiple_conditions_all, nil, false}, - { - segment_multiple_conditions_all, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, - false, - }, - { - segment_multiple_conditions_all, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - }, - true, - }, - {segment_multiple_conditions_any, nil, false}, - { - segment_multiple_conditions_any, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, - true, - }, - { - segment_multiple_conditions_any, - []*traits.TraitModel{{TraitKey: trait_key_2, TraitValue: trait_value_2}}, - true, - }, - { - segment_multiple_conditions_all, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - }, - true, - }, - {segment_nested_rules, nil, false}, - { - segment_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - }, - false, - }, - { - segment_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - {TraitKey: trait_key_3, TraitValue: trait_value_3}, - }, - true, - }, - {segment_conditions_and_nested_rules, nil, false}, - { - segment_conditions_and_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - }, - false, - }, - { - segment_conditions_and_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - {TraitKey: trait_key_3, TraitValue: trait_value_3}, - }, - true, - }, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - doTestIdentityInSegment(t, c.segment, c.identityTraits, c.expected) - }) - } -} - -func doTestIdentityInSegment(t *testing.T, segment *segments.SegmentModel, identityTraits []*traits.TraitModel, expected bool) { - t.Helper() - - identity := &identities.IdentityModel{ - Identifier: "foo", - IdentityTraits: identityTraits, - EnvironmentAPIKey: "api-key", - } - - assert.Equal(t, expected, segments.EvaluateIdentityInSegment(identity, segment)) -} - -func TestIdentityInSegmentPercentageSplit(t *testing.T) { - cases := []struct { - segmentSplitValue int - identityHashedPercentage int - expectedResult bool - }{ - {10, 1, true}, - {100, 50, true}, - {0, 1, false}, - {10, 20, false}, - } - - _, _, _, _, identity := fixtures.GetFixtures() - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: segments.PercentageSplit, - Value: strconv.Itoa(c.segmentSplitValue), - } - rule := &segments.SegmentRuleModel{ - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{cond}, - } - segment := &segments.SegmentModel{ID: 1, Name: "% split", Rules: []*segments.SegmentRuleModel{rule}} - - utils.MockSetHashedPercentageForObjectIds(func(_ []string, _ int) float64 { - return float64(c.identityHashedPercentage) - }) - result := segments.EvaluateIdentityInSegment(identity, segment) - - assert.Equal(t, c.expectedResult, result) - }) - } - utils.ResetMocks() -} - -func TestIdentityInSegmentPercentageSplitUsesDjangoID(t *testing.T) { - cases := []struct { - identity *identities.IdentityModel - expectedResult bool - }{ - {&identities.IdentityModel{ - DjangoID: 1, - Identifier: "Test", - EnvironmentAPIKey: "key", - }, false}, - {&identities.IdentityModel{ - Identifier: "Test", - EnvironmentAPIKey: "key", - }, true}, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: segments.PercentageSplit, - Value: "50", - } - rule := &segments.SegmentRuleModel{ - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{cond}, - } - segment := &segments.SegmentModel{ID: 1, Name: "% split", Rules: []*segments.SegmentRuleModel{rule}} - - result := segments.EvaluateIdentityInSegment(c.identity, segment) - - assert.Equal(t, result, c.expectedResult) - }) - } -} - -func TestIdentityInSegmentIsSetAndIsNotSet(t *testing.T) { - cases := []struct { - operator segments.ConditionOperator - property string - identityTraits []*traits.TraitModel - expectedResult bool - }{ - {segments.IsSet, "foo", []*traits.TraitModel{{TraitKey: "foo", TraitValue: "bar"}}, true}, - {segments.IsSet, "foo", []*traits.TraitModel{{TraitKey: "not_foo", TraitValue: "bar"}}, false}, - {segments.IsSet, "foo", []*traits.TraitModel{}, false}, - {segments.IsNotSet, "foo", []*traits.TraitModel{}, true}, - {segments.IsNotSet, "foo", []*traits.TraitModel{{TraitKey: "foo", TraitValue: "bar"}}, false}, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: c.operator, - Property: c.property, - } - rule := &segments.SegmentRuleModel{ - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{cond}, - } - segment := &segments.SegmentModel{ID: 1, Name: "IsSet or IsNot", Rules: []*segments.SegmentRuleModel{rule}} - doTestIdentityInSegment(t, segment, c.identityTraits, c.expectedResult) - }) - } -} - -func TestSegmentConditionMatchesTraitValue(t *testing.T) { - cases := []struct { - operator segments.ConditionOperator - traitValue interface{} - conditionValue string - expectedResult bool - }{ - {segments.Equal, "bar", "bar", true}, - {segments.Equal, "bar", "baz", false}, - {segments.Equal, 1, "1", true}, - {segments.Equal, 1, "2", false}, - {segments.Equal, true, "true", true}, - {segments.Equal, false, "false", true}, - {segments.Equal, false, "true", false}, - {segments.Equal, true, "false", false}, - {segments.Equal, 1.23, "1.23", true}, - {segments.Equal, 1.23, "4.56", false}, - {segments.GreaterThan, 2, "1", true}, - {segments.GreaterThan, 1, "1", false}, - {segments.GreaterThan, 0, "1", false}, - {segments.GreaterThan, 2.1, "2.0", true}, - {segments.GreaterThan, 2.1, "2.1", false}, - {segments.GreaterThan, 2.0, "2.1", false}, - {segments.GreaterThanInclusive, 2, "1", true}, - {segments.GreaterThanInclusive, 1, "1", true}, - {segments.GreaterThanInclusive, 0, "1", false}, - {segments.GreaterThanInclusive, 2.1, "2.0", true}, - {segments.GreaterThanInclusive, 2.1, "2.1", true}, - {segments.GreaterThanInclusive, 2.0, "2.1", false}, - {segments.LessThan, 1, "2", true}, - {segments.LessThan, 1, "1", false}, - {segments.LessThan, 1, "0", false}, - {segments.LessThan, 2.0, "2.1", true}, - {segments.LessThan, 2.1, "2.1", false}, - {segments.LessThan, 2.1, "2.0", false}, - {segments.LessThanInclusive, 1, "2", true}, - {segments.LessThanInclusive, 1, "1", true}, - {segments.LessThanInclusive, 1, "0", false}, - {segments.LessThanInclusive, 2.0, "2.1", true}, - {segments.LessThanInclusive, 2.1, "2.1", true}, - {segments.LessThanInclusive, 2.1, "2.0", false}, - {segments.NotEqual, "bar", "baz", true}, - {segments.NotEqual, "bar", "bar", false}, - {segments.NotEqual, 1, "2", true}, - {segments.NotEqual, 1, "1", false}, - {segments.NotEqual, true, "false", true}, - {segments.NotEqual, false, "true", true}, - {segments.NotEqual, false, "false", false}, - {segments.NotEqual, true, "true", false}, - {segments.Contains, "bar", "b", true}, - {segments.Contains, "bar", "bar", true}, - {segments.Contains, "bar", "baz", false}, - {segments.NotContains, "bar", "b", false}, - {segments.NotContains, "bar", "bar", false}, - {segments.NotContains, "bar", "baz", true}, - {segments.Regex, "foo", "[a-z]+", true}, - {segments.Regex, "FOO", "[a-z]+", false}, - - // Semver - {segments.Equal, "1.2.3", "1.2.3:semver", true}, - {segments.Equal, "1.2.4", "1.2.3:semver", false}, - {segments.Equal, "not_a_semver", "1.2.3:semver", false}, - - {segments.NotEqual, "1.0.0", "1.0.0:semver", false}, - {segments.NotEqual, "1.0.1", "1.0.0:semver", true}, - - {segments.GreaterThan, "1.0.1", "1.0.0:semver", true}, - {segments.GreaterThan, "1.0.1", "1.1.0:semver", false}, - {segments.GreaterThan, "1.0.1", "1.0.1:semver", false}, - {segments.GreaterThan, "1.2.4", "1.2.3-pre.2+build.4:semver", true}, - - {segments.LessThan, "1.0.1", "1.0.0:semver", false}, - {segments.LessThan, "1.0.1", "1.1.0:semver", true}, - {segments.LessThan, "1.0.1", "1.0.1:semver", false}, - {segments.LessThan, "1.2.4", "1.2.3-pre.2+build.4:semver", false}, - - {segments.GreaterThanInclusive, "1.0.1", "1.0.0:semver", true}, - {segments.GreaterThanInclusive, "1.0.1", "1.2.0:semver", false}, - {segments.GreaterThanInclusive, "1.0.1", "1.0.1:semver", true}, - {segments.LessThanInclusive, "1.0.0", "1.0.1:semver", true}, - {segments.LessThanInclusive, "1.0.0", "1.0.0:semver", true}, - {segments.LessThanInclusive, "1.0.1", "1.0.0:semver", false}, - - // Modulo - {segments.Modulo, 1, "2|0", false}, - {segments.Modulo, 2, "2|0", true}, - {segments.Modulo, 1.1, "2.1|1.1", true}, - {segments.Modulo, 3, "2|0", false}, - {segments.Modulo, 34.2, "4|3", false}, - {segments.Modulo, 35.0, "4|3", true}, - {segments.Modulo, "foo", "4|3", false}, - {segments.Modulo, "1.0.0", "4|3", false}, - {segments.Modulo, false, "4|3", false}, - - // In - {segments.In, "foo", "", false}, - {segments.In, "foo", "foo,bar", true}, - {segments.In, "bar", "foo,bar", true}, - {segments.In, "ba", "foo,bar", false}, - {segments.In, "foo", "foo", true}, - {segments.In, 1, "1,2,3,4", true}, - {segments.In, 1, "", false}, - {segments.In, 1, "1", true}, - } - - for _, c := range cases { - trStr := fmt.Sprint(c.traitValue) - t.Run(trStr+" "+string(c.operator)+" "+c.conditionValue, func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: c.operator, - Property: "foo", - Value: c.conditionValue, - } - assert.Equal(t, c.expectedResult, cond.MatchesTraitValue(trStr)) - }) - } -} - -func TestSegmentRuleNone(t *testing.T) { - cases := []struct { - iterable []bool - expectedResult bool - }{ - {[]bool{}, true}, - {[]bool{false}, true}, - {[]bool{false, false}, true}, - {[]bool{false, true}, false}, - {[]bool{true, true}, false}, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - assert.Equal(t, c.expectedResult, utils.None(c.iterable)) - }) - } -} diff --git a/flagengine/segments/models.go b/flagengine/segments/models.go index 4395612f..cb4ab138 100644 --- a/flagengine/segments/models.go +++ b/flagengine/segments/models.go @@ -1,13 +1,8 @@ package segments import ( - "math" - "regexp" - "strconv" - "strings" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) type SegmentConditionModel struct { @@ -16,47 +11,6 @@ type SegmentConditionModel struct { Property string `json:"property_"` } -func (m *SegmentConditionModel) MatchesTraitValue(traitValue string) bool { - switch m.Operator { - case Modulo: - return m.modulo(traitValue) - case Regex: - return m.regex(traitValue) - default: - return match(m.Operator, traitValue, m.Value) - } -} - -func (m *SegmentConditionModel) regex(traitValue string) bool { - match, err := regexp.Match(m.Value, []byte(traitValue)) - if err != nil { - return false - } - return match -} - -func (m *SegmentConditionModel) modulo(traitValue string) bool { - values := strings.Split(m.Value, "|") - if len(values) != 2 { - return false - } - - divisor, err := strconv.ParseFloat(values[0], 64) - if err != nil { - return false - } - - remainder, err := strconv.ParseFloat(values[1], 64) - if err != nil { - return false - } - traitValueFloat, err := strconv.ParseFloat(traitValue, 64) - if err != nil { - return false - } - return math.Mod(traitValueFloat, divisor) == remainder -} - type SegmentRuleModel struct { Type RuleType `json:"type"` Rules []*SegmentRuleModel diff --git a/flagengine/utils/fixtures/fixtures.go b/flagengine/utils/fixtures/fixtures.go index 75bfca72..6c529282 100644 --- a/flagengine/utils/fixtures/fixtures.go +++ b/flagengine/utils/fixtures/fixtures.go @@ -3,14 +3,14 @@ package fixtures import ( "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/organisations" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/projects" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/organisations" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/projects" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) const ( diff --git a/flagengine/utils/hashing_test.go b/flagengine/utils/hashing_test.go index adf62ee0..b4ccc916 100644 --- a/flagengine/utils/hashing_test.go +++ b/flagengine/utils/hashing_test.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) func TestGetHashedPercentageForObjectIds(t *testing.T) { diff --git a/flagengine/utils/time_test.go b/flagengine/utils/time_test.go index 3c2f00e8..0fdc743e 100644 --- a/flagengine/utils/time_test.go +++ b/flagengine/utils/time_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) func TestUnmarshal(t *testing.T) { diff --git a/go.mod b/go.mod index ac66e4f2..8ce5c231 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,18 @@ -module github.com/Flagsmith/flagsmith-go-client/v4 +module github.com/Flagsmith/flagsmith-go-client/v5 -go 1.22 +go 1.24 require ( github.com/blang/semver/v4 v4.0.0 github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.10.0 - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 ) require ( github.com/go-resty/resty/v2 v2.16.5 github.com/itlightning/dateparse v0.2.1 + github.com/ohler55/ojg v1.26.10 + github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a ) require ( diff --git a/go.sum b/go.sum index 4cd8d14f..0c7c2fa2 100644 --- a/go.sum +++ b/go.sum @@ -4,16 +4,20 @@ 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/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/itlightning/dateparse v0.2.1 h1:AB0NJTyI0HYcerEUMovKZOiQVBg1mBPxgAnWQwzLP6g= github.com/itlightning/dateparse v0.2.1/go.mod h1:xHlmL8lT0L9JIBlaKotRwsoDYpKJskXpiU9ZwbbSkNA= +github.com/ohler55/ojg v1.26.10 h1:qXq8A0AjzwvO+rKJWv9apNVWxyu3He8lgGZZ+AoEdLA= +github.com/ohler55/ojg v1.26.10/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= diff --git a/models.go b/models.go index 1d4530c1..6fd10941 100644 --- a/models.go +++ b/models.go @@ -4,9 +4,8 @@ import ( "encoding/json" "fmt" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/trait" ) type Flag struct { @@ -17,32 +16,29 @@ type Flag struct { FeatureName string } -type Trait struct { - TraitKey string `json:"trait_key"` - TraitValue interface{} `json:"trait_value"` - Transient bool `json:"transient,omitempty"` -} +type Trait = trait.Trait type IdentityTraits struct { - Identifier string `json:"identifier"` - Traits []*Trait `json:"traits"` - Transient bool `json:"transient,omitempty"` + Identifier string `json:"identifier"` + Traits []*trait.Trait `json:"traits"` + Transient bool `json:"transient,omitempty"` } -func (t *Trait) ToTraitModel() *traits.TraitModel { - return &traits.TraitModel{ - TraitKey: t.TraitKey, - TraitValue: fmt.Sprint(t.TraitValue), +func makeFlagFromEngineEvaluationFlagResult(flagResult *engine_eval.FlagResult) Flag { + value := flagResult.Value + + // Get FeatureID from metadata + featureID := 0 + if flagResult.Metadata != nil { + featureID = flagResult.Metadata.FeatureID } -} -func makeFlagFromFeatureState(featureState *features.FeatureStateModel, identityID string) Flag { return Flag{ - Enabled: featureState.Enabled, - Value: featureState.Value(identityID), + Enabled: flagResult.Enabled, + Value: value, IsDefault: false, - FeatureID: featureState.Feature.ID, - FeatureName: featureState.Feature.Name, + FeatureID: featureID, + FeatureName: flagResult.Name, } } @@ -52,13 +48,10 @@ type Flags struct { defaultFlagHandler func(featureName string) (Flag, error) } -func makeFlagsFromFeatureStates(featureStates []*features.FeatureStateModel, - analyticsProcessor *AnalyticsProcessor, - defaultFlagHandler func(featureName string) (Flag, error), - identityID string) Flags { - flags := make([]Flag, len(featureStates)) - for i, featureState := range featureStates { - flags[i] = makeFlagFromFeatureState(featureState, identityID) +func makeFlagsFromEngineEvaluationResult(evaluationResult *engine_eval.EvaluationResult, analyticsProcessor *AnalyticsProcessor, defaultFlagHandler func(string) (Flag, error)) Flags { + flags := make([]Flag, 0, len(evaluationResult.Flags)) + for _, flagResult := range evaluationResult.Flags { + flags = append(flags, makeFlagFromEngineEvaluationFlagResult(flagResult)) } return Flags{ diff --git a/models_test.go b/models_test.go new file mode 100644 index 00000000..d5044850 --- /dev/null +++ b/models_test.go @@ -0,0 +1,331 @@ +package flagsmith + +import ( + "testing" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" +) + +func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { + tests := []struct { + name string + input *engine_eval.FlagResult + expected Flag + }{ + { + name: "flag with string value", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "test_feature_key", + Name: "test_feature", + Value: "test_value", + }, + expected: Flag{ + Enabled: true, + Value: "test_value", + IsDefault: false, + FeatureID: 0, + FeatureName: "test_feature", + }, + }, + { + name: "flag with boolean value", + input: &engine_eval.FlagResult{ + Enabled: false, + FeatureKey: "bool_feature_key", + Name: "bool_feature", + Value: true, + }, + expected: Flag{ + Enabled: false, + Value: true, + IsDefault: false, + FeatureID: 0, + FeatureName: "bool_feature", + }, + }, + { + name: "flag with double value", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "double_feature_key", + Name: "double_feature", + Value: 42.5, + }, + expected: Flag{ + Enabled: true, + Value: 42.5, + IsDefault: false, + FeatureID: 0, + FeatureName: "double_feature", + }, + }, + { + name: "flag with nil value", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "nil_feature_key", + Name: "nil_feature", + Value: nil, + }, + expected: Flag{ + Enabled: true, + Value: nil, + IsDefault: false, + FeatureID: 0, + FeatureName: "nil_feature", + }, + }, + { + name: "flag with zero values", + input: &engine_eval.FlagResult{ + Enabled: false, + FeatureKey: "", + Name: "", + Value: "", + }, + expected: Flag{ + Enabled: false, + Value: "", + IsDefault: false, + FeatureID: 0, + FeatureName: "", + }, + }, + { + name: "flag with reason field (should be ignored in conversion)", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "reason_feature_key", + Name: "reason_feature", + Reason: stringPtr("TARGETING_MATCH"), + Value: "reason_value", + }, + expected: Flag{ + Enabled: true, + Value: "reason_value", + IsDefault: false, + FeatureID: 0, + FeatureName: "reason_feature", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := makeFlagFromEngineEvaluationFlagResult(tt.input) + + if result.Enabled != tt.expected.Enabled { + t.Errorf("Expected Enabled %v, got %v", tt.expected.Enabled, result.Enabled) + } + if result.Value != tt.expected.Value { + t.Errorf("Expected Value %v, got %v", tt.expected.Value, result.Value) + } + if result.IsDefault != tt.expected.IsDefault { + t.Errorf("Expected IsDefault %v, got %v", tt.expected.IsDefault, result.IsDefault) + } + if result.FeatureID != tt.expected.FeatureID { + t.Errorf("Expected FeatureID %v, got %v", tt.expected.FeatureID, result.FeatureID) + } + if result.FeatureName != tt.expected.FeatureName { + t.Errorf("Expected FeatureName %v, got %v", tt.expected.FeatureName, result.FeatureName) + } + }) + } +} + +func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { + tests := []struct { + name string + input *engine_eval.EvaluationResult + expected []Flag + }{ + { + name: "evaluation result with multiple flags", + input: &engine_eval.EvaluationResult{ + Flags: map[string]*engine_eval.FlagResult{ + "feature1": { + Enabled: true, + FeatureKey: "feature1_key", + Name: "feature1", + Value: "value1", + }, + "feature2": { + Enabled: false, + FeatureKey: "feature2_key", + Name: "feature2", + Value: true, + }, + "feature3": { + Enabled: true, + FeatureKey: "feature3_key", + Name: "feature3", + Value: 123.45, + }, + }, + Segments: []engine_eval.SegmentResult{}, + }, + expected: []Flag{ + { + Enabled: true, + Value: "value1", + IsDefault: false, + FeatureID: 0, + FeatureName: "feature1", + }, + { + Enabled: false, + Value: true, + IsDefault: false, + FeatureID: 0, + FeatureName: "feature2", + }, + { + Enabled: true, + Value: 123.45, + IsDefault: false, + FeatureID: 0, + FeatureName: "feature3", + }, + }, + }, + { + name: "evaluation result with no flags", + input: &engine_eval.EvaluationResult{ + Flags: map[string]*engine_eval.FlagResult{}, + Segments: []engine_eval.SegmentResult{}, + }, + expected: []Flag{}, + }, + { + name: "evaluation result with single flag", + input: &engine_eval.EvaluationResult{ + Flags: map[string]*engine_eval.FlagResult{ + "single_feature": { + Enabled: true, + FeatureKey: "single_feature_key", + Name: "single_feature", + Value: "single_value", + }, + }, + Segments: []engine_eval.SegmentResult{}, + }, + expected: []Flag{ + { + Enabled: true, + Value: "single_value", + IsDefault: false, + FeatureID: 0, + FeatureName: "single_feature", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := makeFlagsFromEngineEvaluationResult(tt.input, nil, nil) + + if len(result.flags) != len(tt.expected) { + t.Errorf("Expected %d flags, got %d", len(tt.expected), len(result.flags)) + return + } + + // Create a map of actual flags by feature name for order-independent comparison + actualFlagsByName := make(map[string]Flag) + for _, flag := range result.flags { + actualFlagsByName[flag.FeatureName] = flag + } + + // Compare each expected flag with the corresponding actual flag + for _, expectedFlag := range tt.expected { + actualFlag, exists := actualFlagsByName[expectedFlag.FeatureName] + if !exists { + t.Errorf("Expected flag %s not found in actual result", expectedFlag.FeatureName) + continue + } + + if actualFlag.Enabled != expectedFlag.Enabled { + t.Errorf("Flag %s: Expected Enabled %v, got %v", expectedFlag.FeatureName, expectedFlag.Enabled, actualFlag.Enabled) + } + if actualFlag.Value != expectedFlag.Value { + t.Errorf("Flag %s: Expected Value %v, got %v", expectedFlag.FeatureName, expectedFlag.Value, actualFlag.Value) + } + if actualFlag.IsDefault != expectedFlag.IsDefault { + t.Errorf("Flag %s: Expected IsDefault %v, got %v", expectedFlag.FeatureName, expectedFlag.IsDefault, actualFlag.IsDefault) + } + if actualFlag.FeatureID != expectedFlag.FeatureID { + t.Errorf("Flag %s: Expected FeatureID %v, got %v", expectedFlag.FeatureName, expectedFlag.FeatureID, actualFlag.FeatureID) + } + if actualFlag.FeatureName != expectedFlag.FeatureName { + t.Errorf("Flag %s: Expected FeatureName %v, got %v", expectedFlag.FeatureName, expectedFlag.FeatureName, actualFlag.FeatureName) + } + } + + // Test that analytics processor and default flag handler are set correctly + if result.analyticsProcessor != nil { + t.Errorf("Expected analyticsProcessor to be nil, got non-nil value") + } + if result.defaultFlagHandler != nil { + t.Errorf("Expected defaultFlagHandler to be nil, got non-nil function") + } + }) + } +} + +func TestMakeFlagsFromEngineEvaluationResultWithProcessorAndHandler(t *testing.T) { + // Mock analytics processor + mockAnalyticsProcessor := &AnalyticsProcessor{} + + // Mock default flag handler + mockDefaultFlagHandler := func(featureName string) (Flag, error) { + return Flag{ + Enabled: false, + Value: "default", + IsDefault: true, + FeatureID: -1, + FeatureName: featureName, + }, nil + } + + input := &engine_eval.EvaluationResult{ + Flags: map[string]*engine_eval.FlagResult{ + "test_feature": { + Enabled: true, + FeatureKey: "test_feature_key", + Name: "test_feature", + Value: "test_value", + }, + }, + Segments: []engine_eval.SegmentResult{}, + } + + result := makeFlagsFromEngineEvaluationResult(input, mockAnalyticsProcessor, mockDefaultFlagHandler) + + // Test that analytics processor and default flag handler are set correctly + if result.analyticsProcessor != mockAnalyticsProcessor { + t.Errorf("Expected analyticsProcessor to be set correctly") + } + if result.defaultFlagHandler == nil { + t.Errorf("Expected defaultFlagHandler to be set") + } + + // Test that the handler works + if result.defaultFlagHandler != nil { + flag, err := result.defaultFlagHandler("test") + if err != nil { + t.Errorf("Unexpected error from defaultFlagHandler: %v", err) + } + if flag.FeatureName != "test" { + t.Errorf("Expected handler to return flag with name 'test', got %v", flag.FeatureName) + } + if !flag.IsDefault { + t.Errorf("Expected handler to return default flag") + } + } +} + +// Helper functions for creating pointers. +func stringPtr(s string) *string { + return &s +} diff --git a/offline_handler.go b/offline_handler.go index 3a69d19c..6f6235b2 100644 --- a/offline_handler.go +++ b/offline_handler.go @@ -4,7 +4,7 @@ import ( "encoding/json" "os" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" ) type OfflineHandler interface { diff --git a/offline_handler_test.go b/offline_handler_test.go index 1175ef07..5e695d99 100644 --- a/offline_handler_test.go +++ b/offline_handler_test.go @@ -3,7 +3,7 @@ package flagsmith_test import ( "testing" - flagsmith "github.com/Flagsmith/flagsmith-go-client/v4" + flagsmith "github.com/Flagsmith/flagsmith-go-client/v5" "github.com/stretchr/testify/assert" ) diff --git a/realtime.go b/realtime.go index 8bc74559..a0c77d8e 100644 --- a/realtime.go +++ b/realtime.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" ) // realtime handles the SSE connection and reconnection logic. diff --git a/trait/trait.go b/trait/trait.go new file mode 100644 index 00000000..3165f6ea --- /dev/null +++ b/trait/trait.go @@ -0,0 +1,22 @@ +package trait + +import ( + "fmt" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" +) + +// Trait represents a trait with key-value pair. +type Trait struct { + TraitKey string `json:"trait_key"` + TraitValue interface{} `json:"trait_value"` + Transient bool `json:"transient,omitempty"` +} + +// ToTraitModel converts a Trait to a TraitModel. +func (t *Trait) ToTraitModel() *traits.TraitModel { + return &traits.TraitModel{ + TraitKey: t.TraitKey, + TraitValue: fmt.Sprint(t.TraitValue), + } +}