From 8a599568df0e96a7a00860cab18778057d1352fd Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 28 Aug 2026 22:55:18 +0200 Subject: [PATCH 1/3] feat: global variables --- .github/workflows/release.yml | 4 +- Makefile | 4 +- README.md | 89 ++- child.go | 152 ++++- cmd/commands/assess_distribution.go | 2 +- cmd/commands/benchmark.go | 23 +- cmd/commands/test.go | 72 ++- cmd/commands/test_evaluation_value_test.go | 2 +- cmd/commands/test_types.go | 2 +- cmd/main.go | 2 +- conformance/sdk-v3.json | 621 ++++++++++++++++++++- conformance_test.go | 205 ++++++- emitter.go | 10 +- evaluate.go | 277 ++++++--- evaluation.go | 27 +- evaluation_data_provider.go | 45 +- events.go | 274 +++++++-- go.mod | 2 +- instance.go | 244 +++++++- modules.go | 14 +- openfeature/go.mod | 6 +- openfeature/provider.go | 73 ++- openfeature/provider_test.go | 14 +- public_api_test.go | 2 +- sdk_types.go | 49 +- 25 files changed, 1930 insertions(+), 285 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f0d50b..edfd050 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,9 +28,9 @@ jobs: shell: bash run: | if [[ "$GITHUB_REF_NAME" == openfeature/* ]]; then - [[ "$GITHUB_REF_NAME" =~ ^openfeature/v2\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] + [[ "$GITHUB_REF_NAME" =~ ^openfeature/v3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] else - [[ "$GITHUB_REF_NAME" =~ ^v2\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] + [[ "$GITHUB_REF_NAME" =~ ^v3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] fi make verify-packages diff --git a/Makefile b/Makefile index 57ed6da..5f6ed2d 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,8 @@ test-example-1: go run cmd/main.go test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures verify-packages: - test "$$(go list -m)" = "github.com/featurevisor/featurevisor-go/v2" - test "$$(cd openfeature && GOWORK=off go list -m)" = "github.com/featurevisor/featurevisor-go/openfeature/v2" + test "$$(go list -m)" = "github.com/featurevisor/featurevisor-go/v3" + test "$$(cd openfeature && GOWORK=off go list -m)" = "github.com/featurevisor/featurevisor-go/openfeature/v3" (cd openfeature && GOWORK=off go list -deps ./... >/dev/null) clean: diff --git a/README.md b/README.md index 8194fb7..f40c15c 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam - [Getting variation](#getting-variation) - [Getting variables](#getting-variables) - [Type specific methods](#type-specific-methods) +- [Getting global variables](#getting-global-variables) - [Getting all evaluations](#getting-all-evaluations) -- [Sticky](#sticky) +- [Sticky features and variables](#sticky-features-and-variables) - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) @@ -37,7 +38,8 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - - [`sticky_set`](#sticky_set) + - [`sticky_features_set`](#sticky_features_set) + - [`sticky_variables_set`](#sticky_variables_set) - [`error`](#error) - [Evaluation details](#evaluation-details) - [Modules](#modules) @@ -63,7 +65,7 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam In your Go application, install the SDK using Go modules: ```bash -go get github.com/featurevisor/featurevisor-go/v2 +go get github.com/featurevisor/featurevisor-go/v3 ``` ## Public API @@ -78,7 +80,7 @@ f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Most applications only need `CreateFeaturevisor`, the `Featurevisor` instance type, and `FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types. -Concurrent evaluations are safe after an instance is configured. Do not call state-changing methods such as `SetDatafile`, `SetContext`, `SetSticky`, `AddModule`, `RemoveModule`, or `Close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. +Concurrent evaluations are safe after an instance is configured. Do not call state-changing methods such as `SetDatafile`, `SetContext`, `SetStickyFeatures`, `SetStickyVariables`, `AddModule`, `RemoveModule`, or `Close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. ## Initialization @@ -91,7 +93,7 @@ import ( "io" "net/http" - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) func main() { @@ -153,7 +155,7 @@ You can set context at the time of initialization: ```go import ( - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ @@ -302,12 +304,24 @@ _ = f.GetVariableObjectInto(featureKey, variableKey, context, &cfg) `context` and `OverrideOptions` are optional and can be passed before the output pointer. +## Getting global variables + +Global variables are independent configuration values. Go uses explicit method names because it does not support method overloading: + +```go +supportEmail := f.GetGlobalVariableString("supportEmail", context) +settings := f.GetGlobalVariableObject("checkoutSettings", context) +evaluation := f.EvaluateGlobalVariable("supportEmail", context) +``` + +The complete family includes `GetGlobalVariable`, the Boolean, string, integer, double, array, object, and JSON helpers, `GetGlobalVariableKeys`, and `GetVariableEvaluations`. Global variables support sticky values, `requiredFeatures`, ordered overrides, detailed override keys and paths, and caller defaults through `OverrideOptions`. + ## Getting all evaluations You can get evaluations of all features available in the SDK instance: ```go -allEvaluations := f.GetAllEvaluations(featurevisor.Context{}) +allEvaluations := f.GetFeatureEvaluations(featurevisor.Context{}, nil, featurevisor.OverrideOptions{}) fmt.Printf("%+v\n", allEvaluations) // { @@ -328,21 +342,21 @@ fmt.Printf("%+v\n", allEvaluations) This is handy especially when you want to pass all evaluations from a backend application to the frontend. -## Sticky +## Sticky features and variables For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): -Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; create a child with `SpawnOptions{Sticky: ...}` when a child needs its own sticky state. +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides. Child sticky maps replace parent sticky state rather than inheriting it. ### Initialize with sticky ```go import ( - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ - Sticky: &featurevisor.StickyFeatures{ + StickyFeatures: &featurevisor.StickyFeatures{ "myFeatureKey": { Enabled: true, // optional @@ -368,7 +382,7 @@ Once initialized with sticky features, the SDK will look for values there first You can also set sticky features after the SDK is initialized: ```go -f.SetSticky(featurevisor.StickyFeatures{ +f.SetStickyFeatures(featurevisor.StickyFeatures{ "myFeatureKey": { Enabled: true, Variation: func() *featurevisor.VariationValue { @@ -383,6 +397,10 @@ f.SetSticky(featurevisor.StickyFeatures{ Enabled: false, }, }, true) // replace existing sticky features (false by default) + +f.SetStickyVariables(featurevisor.StickyVariables{ + "supportEmail": "sticky@example.com", +}, true) ``` ## Setting datafile @@ -403,7 +421,7 @@ By default, `SetDatafile(datafile)` merges the incoming datafile with the SDK in - existing `Features` and `Segments` that are missing from the incoming datafile are kept - `Revision`, `SchemaVersion`, and `FeaturevisorVersion` are taken from the incoming datafile -This means you can call `SetDatafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. +This means you can call `SetDatafile` more than once with different datafiles, and the SDK instance accumulates their features, segments, and global variables together. ### Replacing @@ -465,7 +483,7 @@ import ( "io" "net/http" - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) func updateDatafile(f *featurevisor.Featurevisor, datafileURL string) { @@ -552,6 +570,7 @@ unsubscribe := f.On(featurevisor.EventNameDatafileSet, func(details featurevisor // list of feature keys that have new updates, // and you should re-evaluate them features := details["features"] + variables := details["variables"] // handle here }) @@ -560,7 +579,7 @@ unsubscribe := f.On(featurevisor.EventNameDatafileSet, func(details featurevisor unsubscribe() ``` -The `features` array will contain keys of features that have either been: +The `features` and `variables` arrays include entities changed directly or affected through segment and required feature dependencies. - added, or - updated, or @@ -579,10 +598,10 @@ unsubscribe := f.On(featurevisor.EventNameContextSet, func(details featurevisor. }) ``` -### `sticky_set` +### `sticky_features_set` ```go -unsubscribe := f.On(featurevisor.EventNameStickySet, func(details featurevisor.EventDetails) { +unsubscribe := f.On(featurevisor.EventNameStickyFeaturesSet, func(details featurevisor.EventDetails) { replaced := details["replaced"] // true if sticky features got replaced features := details["features"] // list of all affected feature keys @@ -646,7 +665,7 @@ If `Setup` panics, the module is not registered. Featurevisor removes subscripti ```go import ( - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) myCustomModule := &featurevisor.FeaturevisorModule{ @@ -662,8 +681,8 @@ myCustomModule := &featurevisor.FeaturevisorModule{ }) }, - // before evaluation - Before: func(options featurevisor.EvaluateOptions) featurevisor.EvaluateOptions { + // before a feature or global variable evaluation + BeforeEvaluation: func(options featurevisor.EvaluateOptions) featurevisor.EvaluateOptions { // update context before evaluation if options.Context == nil { options.Context = featurevisor.Context{} @@ -672,8 +691,8 @@ myCustomModule := &featurevisor.FeaturevisorModule{ return options }, - // after evaluation - After: func(evaluation featurevisor.Evaluation, options featurevisor.EvaluateOptions) featurevisor.Evaluation { + // after a feature or global variable evaluation + AfterEvaluation: func(evaluation featurevisor.Evaluation, options featurevisor.EvaluateOptions) featurevisor.Evaluation { if evaluation.Reason == "error" { // log error return evaluation @@ -699,13 +718,23 @@ myCustomModule := &featurevisor.FeaturevisorModule{ } ``` +### `sticky_variables_set` + +```go +unsubscribe := f.On(featurevisor.EventNameStickyVariablesSet, func(details featurevisor.EventDetails) { + variables := details["variables"] + replaced := details["replaced"] + fmt.Println(variables, replaced) +}) +``` + ### Registering modules You can register modules at the time of SDK initialization: ```go import ( - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ @@ -852,15 +881,15 @@ go run cmd/main.go assess-distribution \ The OpenFeature provider is a separate Go module, so applications that do not use OpenFeature do not receive its dependencies: ```bash -go get github.com/featurevisor/featurevisor-go/openfeature/v2 +go get github.com/featurevisor/featurevisor-go/openfeature/v3 ``` ```go import ( "context" - featurevisor "github.com/featurevisor/featurevisor-go/v2" - featurevisorof "github.com/featurevisor/featurevisor-go/openfeature/v2" + featurevisor "github.com/featurevisor/featurevisor-go/v3" + featurevisorof "github.com/featurevisor/featurevisor-go/openfeature/v3" of "github.com/open-feature/go-sdk/openfeature" ) @@ -883,9 +912,9 @@ enabled, err := client.BooleanValue( ) ``` -Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, objects, and JSON variables use the object resolver. +Use `checkout` for a flag, `checkout:variation` for its variation, `checkout:title` for a feature variable, and `variable:supportEmail` for a global variable. Boolean variables use the boolean resolver. Arrays, objects, and JSON variables use the object resolver. -OpenFeature's targeting key maps to `userId` by default. `TargetingKeyField`, `KeySeparator`, and `VariationKey` can customize the mapping. The provider's separate module follows the Go version requirement of the official OpenFeature Go SDK. +OpenFeature's targeting key maps to `userId` by default. `TargetingKeyField`, `KeySeparator`, `VariationKey`, and `GlobalVariablePrefix` can customize the mapping. The global prefix defaults to `variable` and cannot contain the separator. The provider's separate module follows the Go version requirement of the official OpenFeature Go SDK. You can also reuse an existing Featurevisor instance: @@ -922,8 +951,8 @@ go test ./... ### Releasing -- Tag the core SDK as `v2.x.y`. -- Tag the provider module separately as `openfeature/v2.x.y`. +- Tag the core SDK as `v3.x.y`. +- Tag the provider module separately as `openfeature/v3.x.y`. - Run `make verify-packages` before creating either release. - Create the matching releases on [GitHub](https://github.com/featurevisor/featurevisor-go/releases). diff --git a/child.go b/child.go index 9499a53..9ce1087 100644 --- a/child.go +++ b/child.go @@ -4,9 +4,10 @@ import "fmt" // childOptions contains options for creating a child instance type childOptions struct { - Parent *Featurevisor - Context Context - Sticky *StickyFeatures + Parent *Featurevisor + Context Context + Sticky *StickyFeatures + StickyVariables *StickyVariables } type childParentSubscription struct { @@ -19,6 +20,7 @@ type FeaturevisorChild struct { parent *Featurevisor context Context sticky *StickyFeatures + stickyVariables *StickyVariables emitter *emitter parentSubscriptions []childParentSubscription nextSubscriptionID uint64 @@ -27,16 +29,17 @@ type FeaturevisorChild struct { // newFeaturevisorChild creates a new child instance. func newFeaturevisorChild(options childOptions) *FeaturevisorChild { return &FeaturevisorChild{ - parent: options.Parent, - context: options.Context, - sticky: options.Sticky, - emitter: newEmitter(), + parent: options.Parent, + context: options.Context, + sticky: options.Sticky, + stickyVariables: options.StickyVariables, + emitter: newEmitter(), } } // On adds an event listener func (c *FeaturevisorChild) On(eventName EventName, callback EventCallback) Unsubscribe { - if eventName == EventNameContextSet || eventName == EventNameStickySet { + if eventName == EventNameContextSet || eventName == EventNameStickySet || eventName == EventNameStickyFeaturesSet || eventName == EventNameStickyVariablesSet { return c.emitter.On(eventName, callback) } @@ -65,6 +68,28 @@ func (c *FeaturevisorChild) On(eventName EventName, callback EventCallback) Unsu return unsubscribe } +// SetStickyFeatures sets sticky feature evaluations on the child. +func (c *FeaturevisorChild) SetStickyFeatures(sticky StickyFeatures, replace ...bool) { + c.SetSticky(sticky, replace...) +} + +// SetStickyVariables sets sticky global variable values on the child. +func (c *FeaturevisorChild) SetStickyVariables(sticky StickyVariables, replace ...bool) { + replaceValue := len(replace) > 0 && replace[0] + next := StickyVariables{} + if !replaceValue && c.stickyVariables != nil { + for key, value := range *c.stickyVariables { + next[key] = value + } + } + for key, value := range sticky { + next[key] = value + } + c.stickyVariables = &next + c.emitter.Trigger(EventNameStickyVariablesSet, EventDetails{"variables": mapKeys(next), "replaced": replaceValue}) + c.emitter.Trigger(EventNameStickySet, EventDetails{"features": []string{}, "variables": mapKeys(next), "replaced": replaceValue}) +} + // Close closes child instance listeners func (c *FeaturevisorChild) Close() { for _, subscription := range append([]childParentSubscription{}, c.parentSubscriptions...) { @@ -138,6 +163,7 @@ func (c *FeaturevisorChild) SetSticky(sticky StickyFeatures, replace ...bool) { params := getParamsForStickySetEvent(previousStickyFeatures, *c.sticky, replaceValue) c.emitter.Trigger(EventNameStickySet, EventDetails(params)) + c.emitter.Trigger(EventNameStickyFeaturesSet, EventDetails(params)) } // getEvaluationDependencies gets evaluation dependencies @@ -155,10 +181,114 @@ func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options O modulesManager: c.parent.modulesManager, instanceEvaluationDataProvider: c.parent.instanceEvaluationDataProvider, sticky: sticky, - DefaultVariationValue: options.DefaultVariationValue, - DefaultVariableValue: options.DefaultVariableValue, - DefaultVariableValueSet: options.DefaultVariableValueSet, + stickyVariables: func() *StickyVariables { + if options.stickyVariables != nil { + return options.stickyVariables + } + return c.stickyVariables + }(), + DefaultVariationValue: options.DefaultVariationValue, + DefaultVariableValue: options.DefaultVariableValue, + DefaultVariableValueSet: options.DefaultVariableValueSet, + } +} + +func mapKeys(values StickyVariables) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +// EvaluateGlobalVariable evaluates an independently defined variable on the child. +func (c *FeaturevisorChild) EvaluateGlobalVariable(variableKey string, args ...interface{}) Evaluation { + context, options := parseEvaluationArgs(args) + key := VariableKey(variableKey) + return evaluateWithModules(EvaluateOptions{evaluateParams: evaluateParams{Type: EvaluationTypeVariable, VariableKey: &key, GlobalVariable: true}, evaluateDependencies: c.getEvaluationDependencies(context, options)}) +} + +// GetGlobalVariable gets an independently defined variable on the child. +func (c *FeaturevisorChild) GetGlobalVariable(variableKey string, args ...interface{}) VariableValue { + evaluation := c.EvaluateGlobalVariable(variableKey, args...) + if evaluation.VariableValue == nil { + return nil + } + return evaluation.VariableValue +} + +func (c *FeaturevisorChild) GetGlobalVariableBoolean(variableKey string, args ...interface{}) *bool { + value, ok := c.GetGlobalVariable(variableKey, args...).(bool) + if !ok { + return nil + } + return &value +} +func (c *FeaturevisorChild) GetGlobalVariableString(variableKey string, args ...interface{}) *string { + value, ok := c.GetGlobalVariable(variableKey, args...).(string) + if !ok { + return nil } + return &value +} +func (c *FeaturevisorChild) GetGlobalVariableInteger(variableKey string, args ...interface{}) *int { + value := c.GetGlobalVariable(variableKey, args...) + if number, ok := value.(float64); ok { + result := int(number) + return &result + } + if number, ok := value.(int); ok { + return &number + } + return nil +} +func (c *FeaturevisorChild) GetGlobalVariableDouble(variableKey string, args ...interface{}) *float64 { + value, ok := c.GetGlobalVariable(variableKey, args...).(float64) + if !ok { + return nil + } + return &value +} +func (c *FeaturevisorChild) GetGlobalVariableArray(variableKey string, args ...interface{}) []string { + value := c.GetGlobalVariable(variableKey, args...) + if result, ok := value.([]string); ok { + return result + } + if values, ok := value.([]interface{}); ok { + result := make([]string, 0, len(values)) + for _, item := range values { + if text, ok := item.(string); ok { + result = append(result, text) + } + } + return result + } + return nil +} +func (c *FeaturevisorChild) GetGlobalVariableObject(variableKey string, args ...interface{}) map[string]interface{} { + value, _ := c.GetGlobalVariable(variableKey, args...).(map[string]interface{}) + return value +} +func (c *FeaturevisorChild) GetGlobalVariableJSON(variableKey string, args ...interface{}) interface{} { + return c.GetGlobalVariable(variableKey, args...) +} + +// GetVariableEvaluations evaluates a global variable snapshot on the child. +func (c *FeaturevisorChild) GetVariableEvaluations(context Context, variableKeys []string, options OverrideOptions) EvaluatedVariables { + result := EvaluatedVariables{} + keys := variableKeys + if len(keys) == 0 { + keys = c.parent.GetGlobalVariableKeys() + } + for _, key := range keys { + result[key] = c.GetGlobalVariable(key, context, options) + } + return result +} + +// GetFeatureEvaluations evaluates a feature snapshot on the child. +func (c *FeaturevisorChild) GetFeatureEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { + return c.GetAllEvaluations(context, featureKeys, options) } func (c *FeaturevisorChild) evaluateFlag(featureKey string, context Context, options OverrideOptions) Evaluation { diff --git a/cmd/commands/assess_distribution.go b/cmd/commands/assess_distribution.go index 82f2b41..9716dbd 100644 --- a/cmd/commands/assess_distribution.go +++ b/cmd/commands/assess_distribution.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) // UUID_LENGTHS matches the TypeScript implementation diff --git a/cmd/commands/benchmark.go b/cmd/commands/benchmark.go index 59541d2..6354a55 100644 --- a/cmd/commands/benchmark.go +++ b/cmd/commands/benchmark.go @@ -6,7 +6,7 @@ import ( "strings" "time" - featurevisor "github.com/featurevisor/featurevisor-go/v2" + featurevisor "github.com/featurevisor/featurevisor-go/v3" ) // BenchmarkOutput represents the result of a benchmark operation @@ -84,6 +84,12 @@ func benchmarkFeatureVariable( }) } +func benchmarkGlobalVariable(instance *featurevisor.Featurevisor, variableKey string, context featurevisor.Context, n int) BenchmarkOutput { + return benchmarkEvaluation(n, func() interface{} { + return instance.GetGlobalVariable(variableKey, context, featurevisor.OverrideOptions{}) + }) +} + func formatDurationMs(duration time.Duration) string { return fmt.Sprintf("%.6fms", float64(duration.Nanoseconds())/1_000_000.0) } @@ -140,8 +146,8 @@ func runBenchmark(opts CLIOptions) { return } - if opts.Feature == "" { - fmt.Println("Feature is required") + if opts.Feature == "" && opts.Variable == "" { + fmt.Println("Feature or global variable is required") return } @@ -165,7 +171,11 @@ func runBenchmark(opts CLIOptions) { level := featurevisor.LogLevel(levelStr) fmt.Println("") - fmt.Printf("Running benchmark for feature \"%s\"...\n", opts.Feature) + if opts.Feature == "" { + fmt.Printf("Running benchmark for global variable \"%s\"...\n", opts.Variable) + } else { + fmt.Printf("Running benchmark for feature \"%s\"...\n", opts.Feature) + } fmt.Println("") datafileBuildStart := time.Now() @@ -206,7 +216,10 @@ func runBenchmark(opts CLIOptions) { fmt.Printf("Against context: %s\n", string(contextJSON)) var output BenchmarkOutput - if opts.Variation { + if opts.Feature == "" { + fmt.Printf("Evaluating global variable \"%s\" %d times...\n", opts.Variable, opts.N) + output = benchmarkGlobalVariable(instance, opts.Variable, context, opts.N) + } else if opts.Variation { // variation fmt.Printf("Evaluating variation %d times...\n", opts.N) output = benchmarkFeatureVariation(instance, opts.Feature, context, opts.N) diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 15a115b..8f2b45d 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -5,12 +5,58 @@ import ( "fmt" "os" "os/exec" + "reflect" "strings" "time" - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) +func valuesEqual(left, right interface{}) bool { + normalize := func(value interface{}) interface{} { + raw, _ := json.Marshal(value) + var result interface{} + _ = json.Unmarshal(raw, &result) + return result + } + return reflect.DeepEqual(normalize(left), normalize(right)) +} + +// RunTestVariable tests a global variable assertion. +func RunTestVariable(assertion map[string]interface{}, variableKey string, instance *featurevisor.Featurevisor, level string) AssertionResult { + started := time.Now() + context := featurevisor.Context{} + if value, ok := assertion["context"].(map[string]interface{}); ok { + context = featurevisor.Context(value) + } + if raw, ok := assertion["stickyVariables"].(map[string]interface{}); ok { + sticky := featurevisor.StickyVariables{} + for key, value := range raw { + sticky[key] = value + } + instance.SetStickyVariables(sticky, true) + } + options := featurevisor.OverrideOptions{} + if value, ok := assertion["defaultVariableValue"]; ok { + options.DefaultVariableValue = value + options.DefaultVariableValueSet = true + } + evaluation := instance.EvaluateGlobalVariable(variableKey, context, options) + errors := "" + if expected, ok := assertion["expectedValue"]; ok && !valuesEqual(evaluation.VariableValue, expected) { + errors += fmt.Sprintf(" ✘ expectedValue: expected %v but received %v\n", expected, evaluation.VariableValue) + } + if expectedFields, ok := assertion["expectedEvaluation"].(map[string]interface{}); ok { + for key, expected := range expectedFields { + actual := getEvaluationValue(evaluation, key) + if !valuesEqual(actual, expected) { + errors += fmt.Sprintf(" ✘ expectedEvaluation.%s: expected %v but received %v\n", key, expected, actual) + } + } + } + return AssertionResult{HasError: errors != "", Errors: errors, Duration: time.Since(started).Seconds()} +} + // TestFeature tests a feature with the given assertion func RunTestFeature(assertion map[string]interface{}, featureKey string, instance *featurevisor.Featurevisor, level string) AssertionResult { context := featurevisor.Context{} @@ -520,6 +566,8 @@ func getEvaluationValue(evaluation featurevisor.Evaluation, key string) interfac return evaluation.Force case "required": return evaluation.Required + case "requiredFeatures": + return evaluation.RequiredFeatures case "sticky": return evaluation.Sticky case "variation": @@ -543,6 +591,13 @@ func getEvaluationValue(evaluation featurevisor.Evaluation, key string) interfac return *evaluation.VariableOverrideIndex } return nil + case "variableOverrideKey": + if evaluation.VariableOverrideKey != nil { + return *evaluation.VariableOverrideKey + } + return nil + case "variableOverridePath": + return evaluation.VariableOverridePath default: return nil } @@ -655,8 +710,7 @@ func compareValues(actual, expected interface{}) bool { case string, bool, int, float64: return actual == expected default: - // For uncomparable types, return false - return false + return valuesEqual(actual, expected) } } @@ -956,7 +1010,7 @@ func runTest(opts CLIOptions) { for _, test := range tests { testKey := test["key"].(string) assertions := test["assertions"].([]interface{}) - if _, hasFeature := test["feature"]; hasFeature && len(opts.Targets) > 0 { + if _, hasFeature := test["feature"]; (hasFeature || test["variable"] != nil) && len(opts.Targets) > 0 { filtered := make([]interface{}, 0, len(assertions)) for _, raw := range assertions { assertion, ok := raw.(map[string]interface{}) @@ -1003,6 +1057,16 @@ func runTest(opts CLIOptions) { testResult = RunTestFeature(effectiveAssertion, test["feature"].(string), instance, level) instance.Close() + } else if variableKey, hasVariable := test["variable"].(string); hasVariable { + selectedDatafileKey := datafileCacheKeyForAssertion(assertionMap, datafileCache) + datafile, ok := datafileCache[selectedDatafileKey] + if !ok { + fmt.Printf("missing datafile for key: %s\n", selectedDatafileKey) + os.Exit(1) + } + instance := buildInstanceForAssertion(datafile, level, assertionMap) + testResult = RunTestVariable(assertionMap, variableKey, instance, level) + instance.Close() } else if _, hasSegment := test["segment"]; hasSegment { segmentKey := test["segment"].(string) segment := segmentsByKey[segmentKey] diff --git a/cmd/commands/test_evaluation_value_test.go b/cmd/commands/test_evaluation_value_test.go index 530f895..79420af 100644 --- a/cmd/commands/test_evaluation_value_test.go +++ b/cmd/commands/test_evaluation_value_test.go @@ -3,7 +3,7 @@ package commands import ( "testing" - "github.com/featurevisor/featurevisor-go/v2" + "github.com/featurevisor/featurevisor-go/v3" ) func TestGetEvaluationValueVariableOverrideIndex(t *testing.T) { diff --git a/cmd/commands/test_types.go b/cmd/commands/test_types.go index e831e81..7a4a336 100644 --- a/cmd/commands/test_types.go +++ b/cmd/commands/test_types.go @@ -1,6 +1,6 @@ package commands -import "github.com/featurevisor/featurevisor-go/v2" +import "github.com/featurevisor/featurevisor-go/v3" // AssertionMatrix represents a matrix of assertions type AssertionMatrix map[string][]featurevisor.AttributeValue diff --git a/cmd/main.go b/cmd/main.go index fe699a3..ea6dfeb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/featurevisor/featurevisor-go/v2/cmd/commands" + "github.com/featurevisor/featurevisor-go/v3/cmd/commands" ) func main() { diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index 49396ce..682a73d 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 5, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -81,6 +81,594 @@ "schemaVersionIsInformational": true, "schemaVersionType": "string" }, + "globalVariables": { + "datafile": { + "schemaVersion": "2", + "revision": "global-variables", + "segments": { + "netherlands": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { + "bucketBy": "userId", + "traffic": [] + }, + "variationFeature": { + "bucketBy": "userId", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "force": [{ "segments": "*", "enabled": true, "variation": "treatment" }], + "traffic": [] + }, + "shared": { + "bucketBy": "userId", + "variablesSchema": { + "owned": { "type": "string", "defaultValue": "feature-value" } + }, + "force": [{ "segments": "*", "enabled": true }], + "traffic": [] + } + }, + "variables": { + "shared": { "type": "string", "defaultValue": "global-value" }, + "stringValue": { "type": "string", "defaultValue": "hello" }, + "integerValue": { "type": "integer", "defaultValue": 1 }, + "doubleValue": { "type": "double", "defaultValue": 1.5 }, + "booleanValue": { "type": "boolean", "defaultValue": true }, + "arrayValue": { "type": "array", "defaultValue": ["one", "two"] }, + "objectValue": { "type": "object", "defaultValue": { "enabled": true } }, + "jsonValue": { "type": "json", "defaultValue": "{\"enabled\":true}" }, + "requiredDisabled": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "requiredFeatures": ["disabledFeature"] + }, + "requiredMissingValue": { + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["disabledFeature"] + }, + "requiredUsesDefault": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "useDefaultWhenDisabled": true, + "requiredFeatures": ["disabledFeature"] + }, + "requiredVariation": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "disabled", + "requiredFeatures": [{ "feature": "variationFeature", "variation": "treatment" }] + }, + "overrideRequirement": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + } + ] + }, + "orderedOverrides": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "nl-pro", + "keyPath": ["europe", "netherlands", "pro"], + "segments": "netherlands", + "conditions": { + "attribute": "plan", + "operator": "equals", + "value": "pro" + }, + "requiredFeatures": ["enabledFeature"], + "value": "matched" + }, + { "key": "catch-all", "segments": "*", "value": "fallback" } + ] + } + } + }, + "cases": [ + { + "name": "string default", + "key": "stringValue", + "expectedValue": "hello", + "expectedReason": "variable_default" + }, + { + "name": "integer default", + "key": "integerValue", + "expectedValue": 1, + "expectedReason": "variable_default" + }, + { + "name": "double default", + "key": "doubleValue", + "expectedValue": 1.5, + "expectedReason": "variable_default" + }, + { + "name": "boolean default", + "key": "booleanValue", + "expectedValue": true, + "expectedReason": "variable_default" + }, + { + "name": "array default", + "key": "arrayValue", + "expectedValue": ["one", "two"], + "expectedReason": "variable_default" + }, + { + "name": "object default", + "key": "objectValue", + "expectedValue": { "enabled": true }, + "expectedReason": "variable_default" + }, + { + "name": "json default", + "key": "jsonValue", + "expectedValue": "{\"enabled\":true}", + "expectedReason": "variable_default" + }, + { + "name": "required unmet with disabled value", + "key": "requiredDisabled", + "expectedValue": "disabled", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet without value", + "key": "requiredMissingValue", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet with caller default", + "key": "requiredMissingValue", + "defaultVariableValue": "caller", + "expectedValue": "caller", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet using variable default", + "key": "requiredUsesDefault", + "expectedValue": "default", + "expectedReason": "required_features_unmet" + }, + { + "name": "required variation matched", + "key": "requiredVariation", + "expectedValue": "matched", + "expectedReason": "variable_default" + }, + { + "name": "unmet override requirement falls through", + "key": "overrideRequirement", + "expectedValue": "default", + "expectedReason": "variable_default" + }, + { + "name": "segment and condition override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "nl", "plan": "pro" }, + "expectedValue": "matched", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 1, + "expectedOverrideKey": "nl-pro", + "expectedOverridePath": ["europe", "netherlands", "pro"] + }, + { + "name": "catch all override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "de", "plan": "pro" }, + "expectedValue": "fallback", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 2, + "expectedOverrideKey": "catch-all" + }, + { + "name": "sticky precedence without definition", + "key": "absent", + "stickyVariables": { "absent": "sticky" }, + "expectedValue": "sticky", + "expectedReason": "sticky" + } + ], + "overloadCase": { + "sharedKey": "shared", + "featureVariableKey": "owned", + "expectedGlobalValue": "global-value", + "expectedFeatureValue": "feature-value" + }, + "datafileUpdateCase": { + "initial": { + "schemaVersion": "2", + "revision": "initial", + "segments": {}, + "features": { + "retained": { "hash": "feature-retained", "bucketBy": "userId", "traffic": [] }, + "changed": { "hash": "feature-old", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "retained": { "hash": "variable-retained", "type": "string", "defaultValue": "retained" }, + "changed": { "hash": "variable-old", "type": "string", "defaultValue": "old" } + } + }, + "merge": { + "schemaVersion": "2", + "revision": "merged", + "segments": {}, + "features": { + "changed": { "hash": "feature-new", "bucketBy": "userId", "traffic": [] }, + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "changed": { "hash": "variable-new", "type": "string", "defaultValue": "new" }, + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterMerge": { + "features": ["added", "changed", "retained"], + "variables": ["added", "changed", "retained"], + "changedFeatures": ["changed", "added"], + "changedVariables": ["changed", "added"] + }, + "replacement": { + "schemaVersion": "2", + "revision": "replaced", + "segments": {}, + "features": { + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterReplacement": { + "features": ["added"], + "variables": ["added"], + "changedFeatures": ["retained", "changed"], + "changedVariables": ["retained", "changed"] + } + }, + "dependencyUpdateCase": { + "modes": [ + { "name": "merge", "replace": false }, + { "name": "replacement", "replace": true } + ], + "initial": { + "schemaVersion": "2", + "revision": "dependencies-initial", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "nl" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "updated": { + "schemaVersion": "2", + "revision": "dependencies-updated", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "de" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-new", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "withoutSegment": { + "schemaVersion": "2", + "revision": "dependencies-without-segment", + "segments": {}, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "expectedChangedFeatures": [ + "prerequisite", + "requiredDependent", + "segmentDependent", + "segmentFeature" + ], + "expectedChangedVariables": ["byRequiredFeature", "bySegment", "bySegmentFeature"], + "expectedRemovedSegmentFeatures": ["segmentDependent", "segmentFeature"], + "expectedRemovedSegmentVariables": ["bySegment", "bySegmentFeature"] + } + }, + "requiredFeatures": { + "datafile": { + "schemaVersion": "2", + "revision": "required-features", + "segments": {}, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { "bucketBy": "userId", "traffic": [] }, + "disabledVariationFeature": { + "bucketBy": "userId", + "disabledVariationValue": "treatment", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "traffic": [] + }, + "stringRequirement": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "explicitEnabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "enabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "disabledFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "missingDisabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "missingFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "enabledAndVariationRequirement": { + "bucketBy": "userId", + "requiredFeatures": [ + { + "feature": "disabledVariationFeature", + "enabled": false, + "variation": "treatment" + } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "multipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": [ + "enabledFeature", + { "feature": "disabledFeature", "enabled": false } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "unmetMultipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature", { "feature": "disabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "canonicalPrecedence": { + "bucketBy": "userId", + "required": ["disabledFeature"], + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "featureVariableOverride": { + "bucketBy": "userId", + "variablesSchema": { + "message": { "type": "string", "defaultValue": "default" } + }, + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "variableOverrides": { + "message": [ + { + "key": "blocked", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "matched", + "requiredFeatures": ["enabledFeature"], + "value": "matched" + } + ] + } + } + ] + } + } + }, + "cases": [ + { + "name": "string requirement defaults to enabled", + "feature": "stringRequirement", + "expectedEnabled": true + }, + { + "name": "explicit enabled true", + "feature": "explicitEnabledRequirement", + "expectedEnabled": true + }, + { + "name": "disabled feature satisfies enabled false", + "feature": "disabledRequirement", + "expectedEnabled": true + }, + { + "name": "missing feature satisfies enabled false", + "feature": "missingDisabledRequirement", + "expectedEnabled": true + }, + { + "name": "enabled and variation both match", + "feature": "enabledAndVariationRequirement", + "expectedEnabled": true + }, + { + "name": "multiple requirements use AND", + "feature": "multipleRequirements", + "expectedEnabled": true + }, + { + "name": "one unmet requirement disables feature", + "feature": "unmetMultipleRequirements", + "expectedEnabled": false + }, + { + "name": "requiredFeatures takes precedence over required", + "feature": "canonicalPrecedence", + "expectedEnabled": true + } + ], + "featureVariableCase": { + "feature": "featureVariableOverride", + "variable": "message", + "expectedValue": "matched", + "expectedOverrideKey": "matched" + } + }, "diagnostics": { "requiredFields": ["level", "code", "message", "details"], "detailsType": "object", @@ -107,11 +695,7 @@ "2024-01-01T00:00:00.250Z", "2024-01-01T01:00:00.250+01:00" ], - "semanticVersions": [ - "1.2.3", - "1.2.3-beta.1", - "1.2.3+build.5" - ], + "semanticVersions": ["1.2.3", "1.2.3-beta.1", "1.2.3+build.5"], "invalidSemanticVersion": "invalid", "invalidSemanticVersionDiagnosticCode": "condition_match_error" }, @@ -171,6 +755,7 @@ ], "childInstances": { "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "stickyStateModel": "child sticky features and variables replace parent sticky state; omitted child sticky options mean empty sticky state", "closeRemovesLocalAndDelegatedSubscriptions": true, "detailedEvaluationMethods": ["flag", "variation", "variable"], "contextCase": { @@ -178,6 +763,30 @@ "child": { "country": "de" }, "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, "expected": { "country": "de", "plan": "free", "region": "eu" } + }, + "stickyCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-sticky", + "segments": {}, + "features": { + "flag": { + "key": "flag", + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "setting": { + "type": "string", + "defaultValue": "datafile" + } + } + }, + "parentStickyFeatures": { "flag": { "enabled": true } }, + "parentStickyVariables": { "setting": "parent-sticky" }, + "expectedParent": { "flag": true, "setting": "parent-sticky" }, + "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" } } }, "defaults": { diff --git a/conformance_test.go b/conformance_test.go index 9d44977..8100f92 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "reflect" "testing" ) @@ -46,6 +47,138 @@ type conformanceFixture struct { } `json:"expected"` } `json:"aggregateCase"` } `json:"defaults"` + GlobalVariables struct { + Datafile DatafileContent `json:"datafile"` + Cases []struct { + Name string `json:"name"` + Key string `json:"key"` + Context Context `json:"context"` + StickyVariables StickyVariables `json:"stickyVariables"` + DefaultVariableValue json.RawMessage `json:"defaultVariableValue"` + ExpectedValue json.RawMessage `json:"expectedValue"` + ExpectedReason EvaluationReason `json:"expectedReason"` + ExpectedOverrideIndex *int `json:"expectedOverrideIndex"` + ExpectedOverrideKey *string `json:"expectedOverrideKey"` + ExpectedOverridePath []string `json:"expectedOverridePath"` + } `json:"cases"` + OverloadCase struct { + SharedKey string `json:"sharedKey"` + FeatureVariableKey string `json:"featureVariableKey"` + ExpectedGlobalValue interface{} `json:"expectedGlobalValue"` + ExpectedFeatureValue interface{} `json:"expectedFeatureValue"` + } `json:"overloadCase"` + DatafileUpdateCase struct { + Initial DatafileContent `json:"initial"` + Merge DatafileContent `json:"merge"` + Replacement DatafileContent `json:"replacement"` + ExpectedAfterMerge struct { + Features []string `json:"features"` + Variables []string `json:"variables"` + ChangedFeatures []string `json:"changedFeatures"` + ChangedVariables []string `json:"changedVariables"` + } `json:"expectedAfterMerge"` + ExpectedAfterReplacement struct { + Features []string `json:"features"` + Variables []string `json:"variables"` + ChangedFeatures []string `json:"changedFeatures"` + ChangedVariables []string `json:"changedVariables"` + } `json:"expectedAfterReplacement"` + } `json:"datafileUpdateCase"` + DependencyUpdateCase struct { + Modes []struct { + Name string `json:"name"` + Replace bool `json:"replace"` + } `json:"modes"` + Initial DatafileContent `json:"initial"` + Updated DatafileContent `json:"updated"` + WithoutSegment DatafileContent `json:"withoutSegment"` + ExpectedChangedFeatures []string `json:"expectedChangedFeatures"` + ExpectedChangedVariables []string `json:"expectedChangedVariables"` + ExpectedRemovedSegmentFeatures []string `json:"expectedRemovedSegmentFeatures"` + ExpectedRemovedSegmentVariables []string `json:"expectedRemovedSegmentVariables"` + } `json:"dependencyUpdateCase"` + } `json:"globalVariables"` + RequiredFeatures struct { + Datafile DatafileContent `json:"datafile"` + Cases []struct { + Name string `json:"name"` + Feature string `json:"feature"` + ExpectedEnabled bool `json:"expectedEnabled"` + } `json:"cases"` + FeatureVariableCase struct { + Feature string `json:"feature"` + Variable string `json:"variable"` + ExpectedValue interface{} `json:"expectedValue"` + ExpectedOverrideKey string `json:"expectedOverrideKey"` + } `json:"featureVariableCase"` + } `json:"requiredFeatures"` +} + +func sameStringSet(actual interface{}, expected []string) bool { + actualValues, ok := actual.([]string) + if !ok { + return false + } + if len(actualValues) != len(expected) { + return false + } + seen := map[string]int{} + for _, value := range actualValues { + seen[value]++ + } + for _, value := range expected { + seen[value]-- + } + for _, count := range seen { + if count != 0 { + return false + } + } + return true +} + +func TestGlobalVariableDatafileUpdates(t *testing.T) { + fixture := loadConformanceFixture(t).GlobalVariables.DatafileUpdateCase + instance := CreateFeaturevisor(FeaturevisorOptions{Datafile: fixture.Initial}) + var details EventDetails + unsubscribe := instance.On(EventNameDatafileSet, func(value EventDetails) { details = value }) + defer unsubscribe() + instance.SetDatafile(fixture.Merge) + if !sameStringSet(instance.GetFeatureKeys(), fixture.ExpectedAfterMerge.Features) || !sameStringSet(instance.GetGlobalVariableKeys(), fixture.ExpectedAfterMerge.Variables) { + t.Fatal("merge did not preserve and add expected entities") + } + if !sameStringSet(details["features"], fixture.ExpectedAfterMerge.ChangedFeatures) || !sameStringSet(details["variables"], fixture.ExpectedAfterMerge.ChangedVariables) { + t.Fatalf("unexpected merge details: %#v", details) + } + instance.SetDatafile(fixture.Replacement, true) + if !sameStringSet(instance.GetFeatureKeys(), fixture.ExpectedAfterReplacement.Features) || !sameStringSet(instance.GetGlobalVariableKeys(), fixture.ExpectedAfterReplacement.Variables) { + t.Fatal("replacement did not retain expected entities") + } + if !sameStringSet(details["features"], fixture.ExpectedAfterReplacement.ChangedFeatures) || !sameStringSet(details["variables"], fixture.ExpectedAfterReplacement.ChangedVariables) { + t.Fatalf("unexpected replacement details: %#v", details) + } +} + +func TestGlobalVariableDependencyUpdates(t *testing.T) { + fixture := loadConformanceFixture(t).GlobalVariables.DependencyUpdateCase + for _, mode := range fixture.Modes { + t.Run(mode.Name, func(t *testing.T) { + instance := CreateFeaturevisor(FeaturevisorOptions{Datafile: fixture.Initial}) + var details EventDetails + instance.On(EventNameDatafileSet, func(value EventDetails) { details = value }) + instance.SetDatafile(fixture.Updated, mode.Replace) + if !sameStringSet(details["features"], fixture.ExpectedChangedFeatures) || !sameStringSet(details["variables"], fixture.ExpectedChangedVariables) { + t.Fatalf("unexpected dependency details: %#v", details) + } + }) + } + instance := CreateFeaturevisor(FeaturevisorOptions{Datafile: fixture.Initial}) + var details EventDetails + instance.On(EventNameDatafileSet, func(value EventDetails) { details = value }) + instance.SetDatafile(fixture.WithoutSegment, true) + if !sameStringSet(details["features"], fixture.ExpectedRemovedSegmentFeatures) || !sameStringSet(details["variables"], fixture.ExpectedRemovedSegmentVariables) { + t.Fatalf("unexpected removed segment details: %#v", details) + } } func loadConformanceFixture(t *testing.T) conformanceFixture { @@ -63,7 +196,7 @@ func loadConformanceFixture(t *testing.T) conformanceFixture { func TestSDKV3ConformanceFixture(t *testing.T) { fixture := loadConformanceFixture(t) - if fixture.Version != 2 { + if fixture.Version != 5 { t.Fatalf("unexpected fixture version %d", fixture.Version) } @@ -141,3 +274,73 @@ func TestSDKV3ConformanceFixture(t *testing.T) { t.Fatalf("aggregate default variation mismatch: %#v", actualDefault) } } + +func TestGlobalVariableConformance(t *testing.T) { + fixture := loadConformanceFixture(t) + for _, item := range fixture.GlobalVariables.Cases { + item := item + t.Run(item.Name, func(t *testing.T) { + options := FeaturevisorOptions{Datafile: fixture.GlobalVariables.Datafile} + if item.StickyVariables != nil { + options.StickyVariables = &item.StickyVariables + } + instance := CreateFeaturevisor(options) + override := OverrideOptions{} + if len(item.DefaultVariableValue) > 0 { + override.DefaultVariableValueSet = true + if err := json.Unmarshal(item.DefaultVariableValue, &override.DefaultVariableValue); err != nil { + t.Fatal(err) + } + } + evaluation := instance.EvaluateGlobalVariable(item.Key, item.Context, override) + if evaluation.Reason != item.ExpectedReason { + t.Fatalf("expected reason %s, got %s", item.ExpectedReason, evaluation.Reason) + } + if len(item.ExpectedValue) > 0 { + var expected interface{} + if err := json.Unmarshal(item.ExpectedValue, &expected); err != nil { + t.Fatal(err) + } + actual := evaluation.VariableValue + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected value %#v, got %#v", expected, actual) + } + } + if !reflect.DeepEqual(evaluation.VariableOverrideIndex, item.ExpectedOverrideIndex) { + t.Fatalf("expected override index %#v, got %#v", item.ExpectedOverrideIndex, evaluation.VariableOverrideIndex) + } + if !reflect.DeepEqual(evaluation.VariableOverrideKey, item.ExpectedOverrideKey) { + t.Fatalf("expected override key %#v, got %#v", item.ExpectedOverrideKey, evaluation.VariableOverrideKey) + } + if !reflect.DeepEqual(evaluation.VariableOverridePath, item.ExpectedOverridePath) { + t.Fatalf("expected override path %#v, got %#v", item.ExpectedOverridePath, evaluation.VariableOverridePath) + } + }) + } + instance := CreateFeaturevisor(FeaturevisorOptions{Datafile: fixture.GlobalVariables.Datafile}) + overload := fixture.GlobalVariables.OverloadCase + if actual := instance.GetGlobalVariable(overload.SharedKey); !reflect.DeepEqual(actual, overload.ExpectedGlobalValue) { + t.Fatalf("global collision value: expected %#v, got %#v", overload.ExpectedGlobalValue, actual) + } + if actual := instance.GetVariable(overload.SharedKey, overload.FeatureVariableKey); !reflect.DeepEqual(actual, overload.ExpectedFeatureValue) { + t.Fatalf("feature collision value: expected %#v, got %#v", overload.ExpectedFeatureValue, actual) + } +} + +func TestRequiredFeaturesConformance(t *testing.T) { + fixture := loadConformanceFixture(t) + instance := CreateFeaturevisor(FeaturevisorOptions{Datafile: fixture.RequiredFeatures.Datafile}) + for _, item := range fixture.RequiredFeatures.Cases { + if actual := instance.IsEnabled(item.Feature); actual != item.ExpectedEnabled { + t.Errorf("%s: expected %v, got %v", item.Name, item.ExpectedEnabled, actual) + } + } + item := fixture.RequiredFeatures.FeatureVariableCase + evaluation := instance.EvaluateVariable(item.Feature, item.Variable) + if !reflect.DeepEqual(evaluation.VariableValue, item.ExpectedValue) { + t.Fatalf("expected feature variable %#v, got %#v", item.ExpectedValue, evaluation.VariableValue) + } + if evaluation.VariableOverrideKey == nil || *evaluation.VariableOverrideKey != item.ExpectedOverrideKey { + t.Fatalf("expected override key %q, got %#v", item.ExpectedOverrideKey, evaluation.VariableOverrideKey) + } +} diff --git a/emitter.go b/emitter.go index 81f742f..945ffd4 100644 --- a/emitter.go +++ b/emitter.go @@ -9,10 +9,12 @@ import ( type EventName string const ( - EventNameDatafileSet EventName = "datafile_set" - EventNameContextSet EventName = "context_set" - EventNameStickySet EventName = "sticky_set" - EventNameError EventName = "error" + EventNameDatafileSet EventName = "datafile_set" + EventNameContextSet EventName = "context_set" + EventNameStickySet EventName = "sticky_set" + EventNameStickyFeaturesSet EventName = "sticky_features_set" + EventNameStickyVariablesSet EventName = "sticky_variables_set" + EventNameError EventName = "error" ) // EventDetails represents additional details for events diff --git a/evaluate.go b/evaluate.go index b246dec..0f19774 100644 --- a/evaluate.go +++ b/evaluate.go @@ -7,9 +7,10 @@ import ( // evaluateParams contains parameters for evaluation type evaluateParams struct { - Type EvaluationType - FeatureKey FeatureKey - VariableKey *VariableKey + Type EvaluationType + FeatureKey FeatureKey + VariableKey *VariableKey + GlobalVariable bool } // evaluateDependencies contains dependencies for evaluation @@ -20,7 +21,8 @@ type evaluateDependencies struct { instanceEvaluationDataProvider *instanceEvaluationDataProvider // Instance-internal sticky state. Consumers configure it on an instance. - sticky *StickyFeatures + sticky *StickyFeatures + stickyVariables *StickyVariables DefaultVariationValue *VariationValue DefaultVariableValue VariableValue @@ -57,16 +59,23 @@ func evaluateWithModules(opts EvaluateOptions) Evaluation { modulesManager := opts.modulesManager modules := modulesManager.GetAll() - // run before modules + // Run the legacy feature callback and the unified evaluation callback. options := opts for _, module := range modules { - if module.Before != nil { + if !opts.GlobalVariable && module.Before != nil { options = module.Before(options) } + if module.BeforeEvaluation != nil { + options = module.BeforeEvaluation(options) + } } // evaluate - evaluation = evaluate(options) + if options.GlobalVariable { + evaluation = evaluateGlobalVariable(options) + } else { + evaluation = evaluate(options) + } // default: variation if opts.DefaultVariationValue != nil && @@ -84,14 +93,169 @@ func evaluateWithModules(opts EvaluateOptions) Evaluation { // run after modules for _, module := range modules { - if module.After != nil { + if !opts.GlobalVariable && module.After != nil { evaluation = module.After(evaluation, options) } + if module.AfterEvaluation != nil { + evaluation = module.AfterEvaluation(evaluation, options) + } } return evaluation } +func cleanRequiredFeatureDependencies(dependencies evaluateDependencies) evaluateDependencies { + dependencies.DefaultVariationValue = nil + dependencies.DefaultVariableValue = nil + dependencies.DefaultVariableValueSet = false + return dependencies +} + +func requiredFeatureParts(required Required) (FeatureKey, bool, *VariationValue, bool) { + switch value := required.(type) { + case string: + return FeatureKey(value), true, nil, true + case RequiredFeature: + expected := true + if value.Enabled != nil { + expected = *value.Enabled + } + return value.Feature, expected, value.Variation, value.Feature != "" + case RequiredWithVariation: + variation := value.Variation + return value.Key, true, &variation, value.Key != "" + case map[string]interface{}: + if feature, ok := value["feature"].(string); ok { + expected := true + if enabled, ok := value["enabled"].(bool); ok { + expected = enabled + } + var variation *VariationValue + if raw, ok := value["variation"].(string); ok { + parsed := VariationValue(raw) + variation = &parsed + } + return FeatureKey(feature), expected, variation, feature != "" + } + if key, ok := value["key"].(string); ok { + var variation *VariationValue + if raw, ok := value["variation"].(string); ok { + parsed := VariationValue(raw) + variation = &parsed + } + return FeatureKey(key), true, variation, key != "" + } + } + return "", true, nil, false +} + +func requiredFeaturesAreMatched(requiredFeatures []Required, dependencies evaluateDependencies) bool { + dependencies = cleanRequiredFeatureDependencies(dependencies) + for _, required := range requiredFeatures { + featureKey, expectedEnabled, expectedVariation, ok := requiredFeatureParts(required) + if !ok { + return false + } + flag := evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{Type: EvaluationTypeFlag, FeatureKey: featureKey}, + evaluateDependencies: dependencies, + }) + if (flag.Enabled != nil && *flag.Enabled) != expectedEnabled { + return false + } + if expectedVariation != nil { + variation := evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{Type: EvaluationTypeVariation, FeatureKey: featureKey}, + evaluateDependencies: dependencies, + }) + var actual *VariationValue + if variation.VariationValue != nil { + actual = variation.VariationValue + } else if variation.Variation != nil { + value := variation.Variation.Value + actual = &value + } + if actual == nil || *actual != *expectedVariation { + return false + } + } + } + return true +} + +func variableOverrideIsMatched(override VariableOverride, dependencies evaluateDependencies) bool { + matchedSelector := false + if override.Conditions != nil { + matchedSelector = dependencies.instanceEvaluationDataProvider.AllConditionsAreMatched( + dependencies.instanceEvaluationDataProvider.parseConditionsIfStringified(override.Conditions), dependencies.Context, + ) + } + if override.Segments != nil { + segmentMatched := dependencies.instanceEvaluationDataProvider.AllSegmentsAreMatched( + dependencies.instanceEvaluationDataProvider.parseSegmentsIfStringified(override.Segments), dependencies.Context, + ) + if override.Conditions == nil { + matchedSelector = segmentMatched + } else { + matchedSelector = matchedSelector && segmentMatched + } + } + if len(override.RequiredFeatures) > 0 { + requiredMatched := requiredFeaturesAreMatched(override.RequiredFeatures, dependencies) + if override.Conditions == nil && override.Segments == nil { + matchedSelector = requiredMatched + } else { + matchedSelector = matchedSelector && requiredMatched + } + } + return matchedSelector +} + +func evaluateGlobalVariable(options EvaluateOptions) Evaluation { + key := GlobalVariableKey("") + if options.VariableKey != nil { + key = GlobalVariableKey(*options.VariableKey) + } + base := Evaluation{Type: EvaluationTypeVariable, VariableKey: options.VariableKey, Reason: EvaluationReasonVariableNotFound} + if options.stickyVariables != nil { + if value, ok := (*options.stickyVariables)[key]; ok { + base.Reason = EvaluationReasonSticky + base.VariableValue = value + return base + } + } + variable := options.instanceEvaluationDataProvider.GetGlobalVariable(key) + if variable == nil { + return base + } + base.GlobalVariable = variable + if variable.Deprecated != nil && *variable.Deprecated { + options.diagnosticReporter.Warn("variable is deprecated", logDetails{"variableKey": key}) + } + if !requiredFeaturesAreMatched(variable.RequiredFeatures, options.evaluateDependencies) { + base.Reason = EvaluationReasonRequiredFeaturesUnmet + if variable.UseDefaultWhenDisabled { + base.VariableValue = variable.DefaultValue + } else if variable.DisabledValue != nil { + base.VariableValue = variable.DisabledValue + } + return base + } + for index, override := range variable.Overrides { + if variableOverrideIsMatched(override, options.evaluateDependencies) { + base.Reason = EvaluationReasonVariableOverrideRule + base.VariableValue = override.Value + base.VariableOverrideIndex = &index + base.VariableOverrideKey = override.Key + base.VariableOverridePath = override.KeyPath + return base + } + } + base.Reason = EvaluationReasonVariableDefault + base.VariableValue = variable.DefaultValue + return base +} + // evaluate evaluates a feature func evaluate(options EvaluateOptions) Evaluation { var evaluation Evaluation @@ -393,65 +557,19 @@ func evaluate(options EvaluateOptions) Evaluation { /** * Required */ - if options.Type == EvaluationTypeFlag && feature.Required != nil && len(feature.Required) > 0 { - requiredFeaturesAreEnabled := true - - for _, required := range feature.Required { - var requiredKey FeatureKey - var requiredVariation *VariationValue - - if requiredStr, ok := required.(string); ok { - requiredKey = FeatureKey(requiredStr) - } else if requiredWithVar, ok := required.(RequiredWithVariation); ok { - requiredKey = requiredWithVar.Key - requiredVariation = &requiredWithVar.Variation - } - - requiredEvaluation := evaluate(EvaluateOptions{ - evaluateParams: evaluateParams{ - Type: EvaluationTypeFlag, - FeatureKey: requiredKey, - }, - evaluateDependencies: options.evaluateDependencies, - }) - requiredIsEnabled := requiredEvaluation.Enabled != nil && *requiredEvaluation.Enabled - - if !requiredIsEnabled { - requiredFeaturesAreEnabled = false - break - } - - if requiredVariation != nil { - requiredVariationEvaluation := evaluate(EvaluateOptions{ - evaluateParams: evaluateParams{ - Type: EvaluationTypeVariation, - FeatureKey: requiredKey, - }, - evaluateDependencies: options.evaluateDependencies, - }) - - var requiredVariationValue *VariationValue - - if requiredVariationEvaluation.VariationValue != nil { - requiredVariationValue = requiredVariationEvaluation.VariationValue - } else if requiredVariationEvaluation.Variation != nil { - requiredVariationValue = &requiredVariationEvaluation.Variation.Value - } - - if requiredVariationValue == nil || *requiredVariationValue != *requiredVariation { - requiredFeaturesAreEnabled = false - break - } - } + if options.Type == EvaluationTypeFlag { + requiredFeatures := feature.RequiredFeatures + if len(requiredFeatures) == 0 { + requiredFeatures = feature.Required } - - if !requiredFeaturesAreEnabled { + if len(requiredFeatures) > 0 && !requiredFeaturesAreMatched(requiredFeatures, options.evaluateDependencies) { evaluation = Evaluation{ - Type: options.Type, - FeatureKey: options.FeatureKey, - Reason: EvaluationReasonRequired, - Required: feature.Required, - Enabled: &[]bool{requiredFeaturesAreEnabled}[0], + Type: options.Type, + FeatureKey: options.FeatureKey, + Reason: EvaluationReasonRequired, + Required: requiredFeatures, + RequiredFeatures: requiredFeatures, + Enabled: &[]bool{false}[0], } options.diagnosticReporter.Debug("required features not enabled", logDetails{ @@ -781,17 +899,7 @@ func evaluate(options EvaluateOptions) Evaluation { if matchedTraffic.VariableOverrides != nil { if overrides, exists := matchedTraffic.VariableOverrides[*options.VariableKey]; exists { for index, override := range overrides { - matched := false - - if override.Conditions != nil { - parsedConditions := options.instanceEvaluationDataProvider.parseConditionsIfStringified(override.Conditions) - matched = options.instanceEvaluationDataProvider.AllConditionsAreMatched(parsedConditions, options.Context) - } else if override.Segments != nil { - parsedSegments := options.instanceEvaluationDataProvider.parseSegmentsIfStringified(override.Segments) - matched = options.instanceEvaluationDataProvider.AllSegmentsAreMatched(parsedSegments, options.Context) - } - - if matched { + if variableOverrideIsMatched(override, options.evaluateDependencies) { overrideIndex := index evaluation = Evaluation{ Type: options.Type, @@ -805,6 +913,8 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: variableSchema, VariableValue: override.Value, VariableOverrideIndex: &overrideIndex, + VariableOverrideKey: override.Key, + VariableOverridePath: override.KeyPath, } options.diagnosticReporter.Debug("variable override from rule", logDetails{ @@ -858,18 +968,7 @@ func evaluate(options EvaluateOptions) Evaluation { if variation.VariableOverrides != nil { if overrides, exists := variation.VariableOverrides[*options.VariableKey]; exists { for index, override := range overrides { - matched := false - - if override.Conditions != nil { - parsedConditions := options.instanceEvaluationDataProvider.parseConditionsIfStringified(override.Conditions) - matched = options.instanceEvaluationDataProvider.AllConditionsAreMatched(parsedConditions, options.Context) - } else if override.Segments != nil { - // Parse segments if they come from JSON unmarshaling - parsedSegments := options.instanceEvaluationDataProvider.parseSegmentsIfStringified(override.Segments) - matched = options.instanceEvaluationDataProvider.AllSegmentsAreMatched(parsedSegments, options.Context) - } - - if matched { + if variableOverrideIsMatched(override, options.evaluateDependencies) { overrideIndex := index evaluation = Evaluation{ Type: options.Type, @@ -888,6 +987,8 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: variableSchema, VariableValue: override.Value, VariableOverrideIndex: &overrideIndex, + VariableOverrideKey: override.Key, + VariableOverridePath: override.KeyPath, } options.diagnosticReporter.Debug("variable override from variation", logDetails{ diff --git a/evaluation.go b/evaluation.go index 2502a23..4dc02af 100644 --- a/evaluation.go +++ b/evaluation.go @@ -20,6 +20,7 @@ const ( EvaluationReasonVariableDisabled EvaluationReason = "variable_disabled" // feature is disabled, and variable's disabledValue is used EvaluationReasonVariableOverrideVariation EvaluationReason = "variable_override_variation" // variable overridden from inside a variation EvaluationReasonVariableOverrideRule EvaluationReason = "variable_override_rule" // variable overridden from inside a rule + EvaluationReasonRequiredFeaturesUnmet EvaluationReason = "required_features_unmet" // Common EvaluationReasonNoMatch EvaluationReason = "no_match" // no rules matched @@ -44,20 +45,21 @@ const ( type Evaluation struct { // Required Type EvaluationType `json:"type"` - FeatureKey FeatureKey `json:"featureKey"` + FeatureKey FeatureKey `json:"featureKey,omitempty"` Reason EvaluationReason `json:"reason"` // Common - BucketKey *BucketKey `json:"bucketKey,omitempty"` - BucketValue *BucketValue `json:"bucketValue,omitempty"` - RuleKey *RuleKey `json:"ruleKey,omitempty"` - Error error `json:"error,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - Traffic *Traffic `json:"traffic,omitempty"` - ForceIndex *int `json:"forceIndex,omitempty"` - Force *Force `json:"force,omitempty"` - Required []Required `json:"required,omitempty"` - Sticky *EvaluatedFeature `json:"sticky,omitempty"` + BucketKey *BucketKey `json:"bucketKey,omitempty"` + BucketValue *BucketValue `json:"bucketValue,omitempty"` + RuleKey *RuleKey `json:"ruleKey,omitempty"` + Error error `json:"error,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Traffic *Traffic `json:"traffic,omitempty"` + ForceIndex *int `json:"forceIndex,omitempty"` + Force *Force `json:"force,omitempty"` + Required []Required `json:"required,omitempty"` + RequiredFeatures []Required `json:"requiredFeatures,omitempty"` + Sticky *EvaluatedFeature `json:"sticky,omitempty"` // Variation Variation *Variation `json:"variation,omitempty"` @@ -67,5 +69,8 @@ type Evaluation struct { VariableKey *VariableKey `json:"variableKey,omitempty"` VariableValue VariableValue `json:"variableValue,omitempty"` VariableSchema *VariableSchema `json:"variableSchema,omitempty"` + GlobalVariable *GlobalVariable `json:"globalVariable,omitempty"` VariableOverrideIndex *int `json:"variableOverrideIndex,omitempty"` + VariableOverrideKey *string `json:"variableOverrideKey,omitempty"` + VariableOverridePath []string `json:"variableOverridePath,omitempty"` } diff --git a/evaluation_data_provider.go b/evaluation_data_provider.go index 412d2b9..79dc011 100644 --- a/evaluation_data_provider.go +++ b/evaluation_data_provider.go @@ -26,6 +26,7 @@ type instanceEvaluationDataProvider struct { revision string segments map[SegmentKey]Segment features map[FeatureKey]Feature + variables map[GlobalVariableKey]GlobalVariable diagnostics *diagnosticReporter regexCache map[string]*regexp.Regexp regexCacheMu sync.RWMutex @@ -38,6 +39,7 @@ func newInstanceEvaluationDataProvider(options instanceEvaluationDataProviderOpt revision: options.Datafile.Revision, segments: options.Datafile.Segments, features: options.Datafile.Features, + variables: options.Datafile.Variables, diagnostics: options.diagnosticReporter, regexCache: make(map[string]*regexp.Regexp), } @@ -103,10 +105,35 @@ func (d *instanceEvaluationDataProvider) GetFeature(featureKey FeatureKey) *Feat if feature.Required != nil { feature.Required = d.parseRequiredIfStringified(feature.Required) } + if feature.RequiredFeatures != nil { + feature.RequiredFeatures = d.parseRequiredIfStringified(feature.RequiredFeatures) + } return &feature } +// GetGlobalVariableKeys returns all independently evaluated variable keys. +func (d *instanceEvaluationDataProvider) GetGlobalVariableKeys() []string { + keys := make([]string, 0, len(d.variables)) + for key := range d.variables { + keys = append(keys, string(key)) + } + return keys +} + +// GetGlobalVariable returns an independently evaluated variable definition. +func (d *instanceEvaluationDataProvider) GetGlobalVariable(variableKey GlobalVariableKey) *GlobalVariable { + variable, exists := d.variables[variableKey] + if !exists { + return nil + } + variable.RequiredFeatures = d.parseRequiredIfStringified(variable.RequiredFeatures) + for index := range variable.Overrides { + variable.Overrides[index].RequiredFeatures = d.parseRequiredIfStringified(variable.Overrides[index].RequiredFeatures) + } + return &variable +} + // GetVariableKeys returns the variable keys for a feature func (d *instanceEvaluationDataProvider) GetVariableKeys(featureKey FeatureKey) []string { feature := d.GetFeature(featureKey) @@ -576,8 +603,20 @@ func (d *instanceEvaluationDataProvider) parseRequiredIfStringified(required []R continue } - // If it's a map, try to parse it as RequiredWithVariation + // If it is a map, parse either the canonical or legacy object form. if reqMap, ok := req.(map[string]interface{}); ok { + if feature, exists := reqMap["feature"].(string); exists { + parsed := RequiredFeature{Feature: FeatureKey(feature)} + if enabled, ok := reqMap["enabled"].(bool); ok { + parsed.Enabled = &enabled + } + if variation, ok := reqMap["variation"].(string); ok { + value := VariationValue(variation) + parsed.Variation = &value + } + parsedRequired[i] = parsed + continue + } if key, exists := reqMap["key"]; exists { if variation, exists := reqMap["variation"]; exists { // Convert to RequiredWithVariation @@ -595,6 +634,10 @@ func (d *instanceEvaluationDataProvider) parseRequiredIfStringified(required []R parsedRequired[i] = req continue } + if _, ok := req.(RequiredFeature); ok { + parsedRequired[i] = req + continue + } // If we can't parse it, keep the original parsedRequired[i] = req diff --git a/events.go b/events.go index 59334ac..14f5ed4 100644 --- a/events.go +++ b/events.go @@ -1,89 +1,253 @@ package featurevisor -// getParamsForDatafileSetEvent gets parameters for datafile set event -func getParamsForDatafileSetEvent( - previousInstanceEvaluationDataProvider *instanceEvaluationDataProvider, - newInstanceEvaluationDataProvider *instanceEvaluationDataProvider, - replace bool, -) logDetails { - previousRevision := "" - if previousInstanceEvaluationDataProvider != nil { - previousRevision = previousInstanceEvaluationDataProvider.GetRevision() +import ( + "encoding/json" + "reflect" +) + +func stringValue(value *string) string { + if value == nil { + return "" } + return *value +} - newRevision := "" - if newInstanceEvaluationDataProvider != nil { - newRevision = newInstanceEvaluationDataProvider.GetRevision() +func changedEntityKeys[T any](before map[string]T, after map[string]T, hash func(T) string) map[string]bool { + changed := map[string]bool{} + for key, oldValue := range before { + newValue, ok := after[key] + if !ok || hash(oldValue) != hash(newValue) { + changed[key] = true + } + } + for key, newValue := range after { + oldValue, ok := before[key] + if !ok || hash(oldValue) != hash(newValue) { + changed[key] = true + } } + return changed +} - previousFeatureKeys := []string{} - if previousInstanceEvaluationDataProvider != nil { - previousFeatureKeys = previousInstanceEvaluationDataProvider.GetFeatureKeys() +func collectSegmentKeys(value interface{}, result map[string]bool) { + if raw, ok := value.(string); ok { + if raw == "*" { + return + } + if len(raw) > 0 && (raw[0] == '{' || raw[0] == '[') { + var parsed interface{} + if json.Unmarshal([]byte(raw), &parsed) == nil { + collectSegmentKeys(parsed, result) + return + } + } + result[raw] = true + return } + switch typed := value.(type) { + case []interface{}: + for _, item := range typed { + collectSegmentKeys(item, result) + } + case map[string]interface{}: + for _, item := range typed { + collectSegmentKeys(item, result) + } + } +} - newFeatureKeys := []string{} - if newInstanceEvaluationDataProvider != nil { - newFeatureKeys = newInstanceEvaluationDataProvider.GetFeatureKeys() +func collectRequiredFeatureKeys(values []Required, result map[string]bool) { + for _, value := range values { + key, _, _, ok := requiredFeatureParts(value) + if ok { + result[key] = true + } } +} - // Find removed features - removedFeatures := []string{} - for _, previousFeatureKey := range previousFeatureKeys { - found := false - for _, newFeatureKey := range newFeatureKeys { - if previousFeatureKey == newFeatureKey { - found = true - break +func featureDependencies(feature Feature) (map[string]bool, map[string]bool) { + segments, features := map[string]bool{}, map[string]bool{} + required := feature.RequiredFeatures + if len(required) == 0 { + required = feature.Required + } + collectRequiredFeatureKeys(required, features) + for _, traffic := range feature.Traffic { + collectSegmentKeys(traffic.Segments, segments) + for _, overrides := range traffic.VariableOverrides { + for _, override := range overrides { + collectSegmentKeys(override.Segments, segments) + collectRequiredFeatureKeys(override.RequiredFeatures, features) } } - if !found { - removedFeatures = append(removedFeatures, previousFeatureKey) + } + for _, force := range feature.Force { + collectSegmentKeys(force.Segments, segments) + } + for _, variation := range feature.Variations { + for _, overrides := range variation.VariableOverrides { + for _, override := range overrides { + collectSegmentKeys(override.Segments, segments) + collectRequiredFeatureKeys(override.RequiredFeatures, features) + } } } + return segments, features +} - // Find changed features - changedFeatures := []string{} - for _, previousFeatureKey := range previousFeatureKeys { - for _, newFeatureKey := range newFeatureKeys { - if previousFeatureKey == newFeatureKey { - // Check if feature was changed by comparing hashes - previousFeature := previousInstanceEvaluationDataProvider.GetFeature(FeatureKey(previousFeatureKey)) - newFeature := newInstanceEvaluationDataProvider.GetFeature(FeatureKey(newFeatureKey)) +func variableDependencies(variable GlobalVariable) (map[string]bool, map[string]bool) { + segments, features := map[string]bool{}, map[string]bool{} + collectRequiredFeatureKeys(variable.RequiredFeatures, features) + for _, override := range variable.Overrides { + collectSegmentKeys(override.Segments, segments) + collectRequiredFeatureKeys(override.RequiredFeatures, features) + } + return segments, features +} - if previousFeature != nil && newFeature != nil { - // Compare hashes if available, otherwise assume changed - if previousFeature.Hash != newFeature.Hash { - changedFeatures = append(changedFeatures, previousFeatureKey) - } +func affectedDatafileEntities(previous, next *instanceEvaluationDataProvider) ([]string, []string) { + oldFeatures, newFeatures := map[string]Feature{}, map[string]Feature{} + oldVariables, newVariables := map[string]GlobalVariable{}, map[string]GlobalVariable{} + oldSegments, newSegments := map[string]Segment{}, map[string]Segment{} + if previous != nil { + for key, value := range previous.features { + oldFeatures[key] = value + } + for key, value := range previous.variables { + oldVariables[key] = value + } + for key, value := range previous.segments { + oldSegments[key] = value + } + } + if next != nil { + for key, value := range next.features { + newFeatures[key] = value + } + for key, value := range next.variables { + newVariables[key] = value + } + for key, value := range next.segments { + newSegments[key] = value + } + } + changedFeatures := changedEntityKeys(oldFeatures, newFeatures, func(value Feature) string { + if value.Hash != nil { + return *value.Hash + } + raw, _ := json.Marshal(value) + return string(raw) + }) + changedVariables := changedEntityKeys(oldVariables, newVariables, func(value GlobalVariable) string { + if value.Hash != nil { + return *value.Hash + } + raw, _ := json.Marshal(value) + return string(raw) + }) + changedSegments := map[string]bool{} + for key, value := range oldSegments { + if nextValue, ok := newSegments[key]; !ok || !reflect.DeepEqual(value, nextValue) { + changedSegments[key] = true + } + } + for key, value := range newSegments { + if oldValue, ok := oldSegments[key]; !ok || !reflect.DeepEqual(value, oldValue) { + changedSegments[key] = true + } + } + allFeatures := map[string]Feature{} + for key, value := range oldFeatures { + allFeatures[key] = value + } + for key, value := range newFeatures { + allFeatures[key] = value + } + for updated := true; updated; { + updated = false + for key, feature := range allFeatures { + if changedFeatures[key] { + continue + } + segments, required := featureDependencies(feature) + dependent := false + for segment := range segments { + if changedSegments[segment] { + dependent = true } - break + } + for requiredKey := range required { + if changedFeatures[requiredKey] { + dependent = true + } + } + if dependent { + changedFeatures[key] = true + updated = true } } } - - // Find added features - addedFeatures := []string{} - for _, newFeatureKey := range newFeatureKeys { - found := false - for _, previousFeatureKey := range previousFeatureKeys { - if newFeatureKey == previousFeatureKey { - found = true - break + allVariables := map[string]GlobalVariable{} + for key, value := range oldVariables { + allVariables[key] = value + } + for key, value := range newVariables { + allVariables[key] = value + } + for key, variable := range allVariables { + if changedVariables[key] { + continue + } + segments, required := variableDependencies(variable) + dependent := false + for segment := range segments { + if changedSegments[segment] { + dependent = true + } + } + for requiredKey := range required { + if changedFeatures[requiredKey] { + dependent = true } } - if !found { - addedFeatures = append(addedFeatures, newFeatureKey) + if dependent { + changedVariables[key] = true } } + features, variables := []string{}, []string{} + for key := range changedFeatures { + features = append(features, key) + } + for key := range changedVariables { + variables = append(variables, key) + } + return features, variables +} + +// getParamsForDatafileSetEvent gets parameters for datafile set event +func getParamsForDatafileSetEvent( + previousInstanceEvaluationDataProvider *instanceEvaluationDataProvider, + newInstanceEvaluationDataProvider *instanceEvaluationDataProvider, + replace bool, +) logDetails { + previousRevision := "" + if previousInstanceEvaluationDataProvider != nil { + previousRevision = previousInstanceEvaluationDataProvider.GetRevision() + } + + newRevision := "" + if newInstanceEvaluationDataProvider != nil { + newRevision = newInstanceEvaluationDataProvider.GetRevision() + } - // Combine all affected feature keys - allAffectedFeatures := append(append(removedFeatures, changedFeatures...), addedFeatures...) + allAffectedFeatures, allAffectedVariables := affectedDatafileEntities(previousInstanceEvaluationDataProvider, newInstanceEvaluationDataProvider) return logDetails{ "revision": newRevision, "previousRevision": previousRevision, "revisionChanged": previousRevision != newRevision, "features": allAffectedFeatures, + "variables": allAffectedVariables, "replaced": replace, } } diff --git a/go.mod b/go.mod index 00fb434..bd0e3b2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/featurevisor/featurevisor-go/v2 +module github.com/featurevisor/featurevisor-go/v3 go 1.21.3 diff --git a/instance.go b/instance.go index fb5ef25..e3407af 100644 --- a/instance.go +++ b/instance.go @@ -7,7 +7,8 @@ import ( // OverrideOptions contains options for overriding evaluation type OverrideOptions struct { - sticky *StickyFeatures + sticky *StickyFeatures + stickyVariables *StickyVariables DefaultVariationValue *VariationValue DefaultVariableValue VariableValue @@ -16,17 +17,21 @@ type OverrideOptions struct { // SpawnOptions configures a child SDK instance. type SpawnOptions struct { - Sticky *StickyFeatures + Sticky *StickyFeatures + StickyFeatures *StickyFeatures + StickyVariables *StickyVariables } // FeaturevisorOptions contains options for creating an instance. type FeaturevisorOptions struct { - Datafile interface{} // DatafileContent | string - Context Context - LogLevel *LogLevel - OnDiagnostic FeaturevisorDiagnosticHandler - Sticky *StickyFeatures - Modules []*FeaturevisorModule + Datafile interface{} // DatafileContent | string + Context Context + LogLevel *LogLevel + OnDiagnostic FeaturevisorDiagnosticHandler + Sticky *StickyFeatures + StickyFeatures *StickyFeatures + StickyVariables *StickyVariables + Modules []*FeaturevisorModule } type moduleDiagnosticSubscription struct { @@ -39,11 +44,12 @@ type moduleDiagnosticSubscription struct { // Featurevisor represents a Featurevisor SDK instance type Featurevisor struct { // from options - context Context - diagnostics *diagnosticReporter - logLevel LogLevel - onDiagnostic FeaturevisorDiagnosticHandler - sticky *StickyFeatures + context Context + diagnostics *diagnosticReporter + logLevel LogLevel + onDiagnostic FeaturevisorDiagnosticHandler + sticky *StickyFeatures + stickyVariables *StickyVariables // internally created datafile DatafileContent @@ -149,6 +155,7 @@ func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { Revision: "unknown", Segments: make(map[SegmentKey]Segment), Features: make(map[FeatureKey]Feature), + Variables: make(map[GlobalVariableKey]GlobalVariable), } instanceEvaluationDataProvider := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ @@ -164,7 +171,13 @@ func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { emitter: emitter, datafile: emptyDatafile, instanceEvaluationDataProvider: instanceEvaluationDataProvider, - sticky: options.Sticky, + sticky: func() *StickyFeatures { + if options.StickyFeatures != nil { + return options.StickyFeatures + } + return options.Sticky + }(), + stickyVariables: options.StickyVariables, } instance.modulesManager = newModulesManager(modulesManagerOptions{ @@ -278,6 +291,54 @@ func (i *Featurevisor) SetSticky(sticky StickyFeatures, replace ...bool) { Details: params, }, nil) i.emitter.Trigger(EventNameStickySet, EventDetails(params)) + i.emitter.Trigger(EventNameStickyFeaturesSet, EventDetails(params)) +} + +// SetStickyFeatures sets sticky feature evaluations. +func (i *Featurevisor) SetStickyFeatures(sticky StickyFeatures, replace ...bool) { + i.SetSticky(sticky, replace...) +} + +// SetStickyVariables sets sticky global variable values. +func (i *Featurevisor) SetStickyVariables(sticky StickyVariables, replace ...bool) { + if i.closed { + return + } + replaceValue := len(replace) > 0 && replace[0] + previous := StickyVariables{} + if i.stickyVariables != nil { + for key, value := range *i.stickyVariables { + previous[key] = value + } + } + next := StickyVariables{} + if !replaceValue { + for key, value := range previous { + next[key] = value + } + } + for key, value := range sticky { + next[key] = value + } + i.stickyVariables = &next + keys := make([]string, 0, len(previous)+len(next)) + seen := map[string]bool{} + for key := range previous { + if !seen[key] { + keys = append(keys, key) + seen[key] = true + } + } + for key := range next { + if !seen[key] { + keys = append(keys, key) + seen[key] = true + } + } + details := logDetails{"variables": keys, "replaced": replaceValue} + i.reportDiagnostic(FeaturevisorDiagnostic{Level: LogLevelInfo, Code: "sticky_variables_set", Message: "Sticky variables set", Details: details}, nil) + i.emitter.Trigger(EventNameStickyVariablesSet, EventDetails(details)) + i.emitter.Trigger(EventNameStickySet, EventDetails{"features": []string{}, "variables": keys, "replaced": replaceValue}) } // GetRevision returns the revision @@ -301,6 +362,11 @@ func (i *Featurevisor) GetVariableKeys(featureKey string) []string { return i.instanceEvaluationDataProvider.GetVariableKeys(FeatureKey(featureKey)) } +// GetGlobalVariableKeys returns all global variable keys. +func (i *Featurevisor) GetGlobalVariableKeys() []string { + return i.instanceEvaluationDataProvider.GetGlobalVariableKeys() +} + func (i *Featurevisor) HasVariations(featureKey string) bool { return i.instanceEvaluationDataProvider.HasVariations(FeatureKey(featureKey)) } @@ -544,7 +610,13 @@ func (i *Featurevisor) Spawn(args ...interface{}) *FeaturevisorChild { return newFeaturevisorChild(childOptions{ Parent: i, Context: i.GetContext(contextValue), - Sticky: optionsValue.Sticky, + Sticky: func() *StickyFeatures { + if optionsValue.StickyFeatures != nil { + return optionsValue.StickyFeatures + } + return optionsValue.Sticky + }(), + StickyVariables: optionsValue.StickyVariables, }) } @@ -563,10 +635,120 @@ func (i *Featurevisor) getEvaluationDependencies(context Context, options Overri modulesManager: i.modulesManager, instanceEvaluationDataProvider: i.instanceEvaluationDataProvider, sticky: sticky, - DefaultVariationValue: options.DefaultVariationValue, - DefaultVariableValue: options.DefaultVariableValue, - DefaultVariableValueSet: options.DefaultVariableValueSet, + stickyVariables: func() *StickyVariables { + if options.stickyVariables != nil { + return options.stickyVariables + } + return i.stickyVariables + }(), + DefaultVariationValue: options.DefaultVariationValue, + DefaultVariableValue: options.DefaultVariableValue, + DefaultVariableValueSet: options.DefaultVariableValueSet, + } +} + +// EvaluateGlobalVariable evaluates an independently defined variable. +func (i *Featurevisor) EvaluateGlobalVariable(variableKey string, args ...interface{}) Evaluation { + context, options := parseEvaluationArgs(args) + key := VariableKey(variableKey) + return evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{Type: EvaluationTypeVariable, VariableKey: &key, GlobalVariable: true}, + evaluateDependencies: i.getEvaluationDependencies(context, options), + }) +} + +// GetGlobalVariable gets an independently defined variable. +func (i *Featurevisor) GetGlobalVariable(variableKey string, args ...interface{}) VariableValue { + evaluation := i.EvaluateGlobalVariable(variableKey, args...) + if evaluation.VariableValue == nil { + return nil + } + if evaluation.GlobalVariable != nil && evaluation.GlobalVariable.Type == VariableTypeJSON { + if raw, ok := evaluation.VariableValue.(string); ok { + var value interface{} + if json.Unmarshal([]byte(raw), &value) == nil { + return value + } + } + } + if evaluation.GlobalVariable != nil && evaluation.Reason == EvaluationReasonVariableDefault { + return getValueByType(evaluation.VariableValue, string(evaluation.GlobalVariable.Type)) + } + return evaluation.VariableValue +} + +func (i *Featurevisor) GetGlobalVariableBoolean(variableKey string, args ...interface{}) *bool { + value, ok := i.GetGlobalVariable(variableKey, args...).(bool) + if !ok { + return nil + } + return &value +} +func (i *Featurevisor) GetGlobalVariableString(variableKey string, args ...interface{}) *string { + value, ok := i.GetGlobalVariable(variableKey, args...).(string) + if !ok { + return nil + } + return &value +} +func (i *Featurevisor) GetGlobalVariableInteger(variableKey string, args ...interface{}) *int { + value := i.GetGlobalVariable(variableKey, args...) + if number, ok := value.(float64); ok { + result := int(number) + return &result + } + if number, ok := value.(int); ok { + return &number + } + return nil +} +func (i *Featurevisor) GetGlobalVariableDouble(variableKey string, args ...interface{}) *float64 { + value := i.GetGlobalVariable(variableKey, args...) + if number, ok := value.(float64); ok { + return &number } + return nil +} +func (i *Featurevisor) GetGlobalVariableArray(variableKey string, args ...interface{}) []string { + value := i.GetGlobalVariable(variableKey, args...) + if result, ok := value.([]string); ok { + return result + } + if values, ok := value.([]interface{}); ok { + result := make([]string, 0, len(values)) + for _, item := range values { + if text, ok := item.(string); ok { + result = append(result, text) + } + } + return result + } + return nil +} +func (i *Featurevisor) GetGlobalVariableObject(variableKey string, args ...interface{}) map[string]interface{} { + value, _ := i.GetGlobalVariable(variableKey, args...).(map[string]interface{}) + return value +} +func (i *Featurevisor) GetGlobalVariableJSON(variableKey string, args ...interface{}) interface{} { + return i.GetGlobalVariable(variableKey, args...) +} + +// GetGlobalVariableArrayInto decodes an array global variable into out. +func (i *Featurevisor) GetGlobalVariableArrayInto(variableKey string, args ...interface{}) error { + context, options, out, err := parseVariableIntoArgs(args...) + if err != nil { + return err + } + value := i.GetGlobalVariable(variableKey, context, options) + if value == nil { + return fmt.Errorf("global variable %q is unavailable", variableKey) + } + return decodeInto(value, out) +} + +// GetGlobalVariableObjectInto decodes an object global variable into out. +func (i *Featurevisor) GetGlobalVariableObjectInto(variableKey string, args ...interface{}) error { + return i.GetGlobalVariableArrayInto(variableKey, args...) } func parseEvaluationArgs(args []interface{}) (Context, OverrideOptions) { @@ -939,6 +1121,24 @@ func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, return result } +// GetFeatureEvaluations evaluates a feature snapshot. +func (i *Featurevisor) GetFeatureEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { + return i.GetAllEvaluations(context, featureKeys, options) +} + +// GetVariableEvaluations evaluates a global variable snapshot. +func (i *Featurevisor) GetVariableEvaluations(context Context, variableKeys []string, options OverrideOptions) EvaluatedVariables { + result := EvaluatedVariables{} + keys := variableKeys + if len(keys) == 0 { + keys = i.GetGlobalVariableKeys() + } + for _, key := range keys { + result[key] = i.GetGlobalVariable(key, context, options) + } + return result +} + func mergeStoredDatafile(existing DatafileContent, incoming DatafileContent) DatafileContent { mergedSegments := map[SegmentKey]Segment{} for key, value := range existing.Segments { @@ -955,6 +1155,13 @@ func mergeStoredDatafile(existing DatafileContent, incoming DatafileContent) Dat for key, value := range incoming.Features { mergedFeatures[key] = value } + mergedVariables := map[GlobalVariableKey]GlobalVariable{} + for key, value := range existing.Variables { + mergedVariables[key] = value + } + for key, value := range incoming.Variables { + mergedVariables[key] = value + } return DatafileContent{ SchemaVersion: incoming.SchemaVersion, @@ -962,6 +1169,7 @@ func mergeStoredDatafile(existing DatafileContent, incoming DatafileContent) Dat FeaturevisorVersion: incoming.FeaturevisorVersion, Segments: mergedSegments, Features: mergedFeatures, + Variables: mergedVariables, } } diff --git a/modules.go b/modules.go index ad6c4f4..ee53165 100644 --- a/modules.go +++ b/modules.go @@ -33,12 +33,14 @@ type FeaturevisorModuleApi struct { type FeaturevisorModule struct { Name string `json:"name,omitempty"` - Setup func(api FeaturevisorModuleApi) `json:"setup,omitempty"` - Before func(options EvaluateOptions) EvaluateOptions `json:"before,omitempty"` - BucketKey ConfigureBucketKey `json:"bucketKey,omitempty"` - BucketValue ConfigureBucketValue `json:"bucketValue,omitempty"` - After func(evaluation Evaluation, options EvaluateOptions) Evaluation `json:"after,omitempty"` - Close func() `json:"close,omitempty"` + Setup func(api FeaturevisorModuleApi) `json:"setup,omitempty"` + Before func(options EvaluateOptions) EvaluateOptions `json:"before,omitempty"` + BeforeEvaluation func(options EvaluateOptions) EvaluateOptions `json:"beforeEvaluation,omitempty"` + BucketKey ConfigureBucketKey `json:"bucketKey,omitempty"` + BucketValue ConfigureBucketValue `json:"bucketValue,omitempty"` + After func(evaluation Evaluation, options EvaluateOptions) Evaluation `json:"after,omitempty"` + AfterEvaluation func(evaluation Evaluation, options EvaluateOptions) Evaluation `json:"afterEvaluation,omitempty"` + Close func() `json:"close,omitempty"` } func getModuleName(module *FeaturevisorModule) string { diff --git a/openfeature/go.mod b/openfeature/go.mod index 665d4a2..335c2e4 100644 --- a/openfeature/go.mod +++ b/openfeature/go.mod @@ -1,9 +1,9 @@ -module github.com/featurevisor/featurevisor-go/openfeature/v2 +module github.com/featurevisor/featurevisor-go/openfeature/v3 go 1.25.0 require ( - github.com/featurevisor/featurevisor-go/v2 v2.0.0 + github.com/featurevisor/featurevisor-go/v3 v3.0.0 github.com/open-feature/go-sdk v1.17.2 ) @@ -12,4 +12,4 @@ require ( go.uber.org/mock v0.6.0 // indirect ) -replace github.com/featurevisor/featurevisor-go/v2 => .. +replace github.com/featurevisor/featurevisor-go/v3 => .. diff --git a/openfeature/provider.go b/openfeature/provider.go index 9f2b480..3a1c455 100644 --- a/openfeature/provider.go +++ b/openfeature/provider.go @@ -10,7 +10,7 @@ import ( "strings" "time" - featurevisor "github.com/featurevisor/featurevisor-go/v2" + featurevisor "github.com/featurevisor/featurevisor-go/v3" of "github.com/open-feature/go-sdk/openfeature" ) @@ -21,31 +21,37 @@ type TrackingEvent struct { } type Options struct { - Featurevisor *featurevisor.Featurevisor - FeaturevisorOptions featurevisor.FeaturevisorOptions - TargetingKeyField string - KeySeparator string - VariationKey string - OnTrack func(TrackingEvent) + Featurevisor *featurevisor.Featurevisor + FeaturevisorOptions featurevisor.FeaturevisorOptions + TargetingKeyField string + KeySeparator string + VariationKey string + GlobalVariablePrefix string + OnTrack func(TrackingEvent) } type Provider struct { - featurevisor *featurevisor.Featurevisor - targetingKeyField string - keySeparator string - variationKey string - onTrack func(TrackingEvent) - datafileError string - datafileUnsubscribe featurevisor.Unsubscribe - ownsFeaturevisor bool + featurevisor *featurevisor.Featurevisor + targetingKeyField string + keySeparator string + variationKey string + globalVariablePrefix string + onTrack func(TrackingEvent) + datafileError string + datafileUnsubscribe featurevisor.Unsubscribe + ownsFeaturevisor bool } func NewProvider(options Options) *Provider { p := &Provider{ - targetingKeyField: valueOr(options.TargetingKeyField, "userId"), - keySeparator: valueOr(options.KeySeparator, ":"), - variationKey: valueOr(options.VariationKey, "variation"), - onTrack: options.OnTrack, + targetingKeyField: valueOr(options.TargetingKeyField, "userId"), + keySeparator: valueOr(options.KeySeparator, ":"), + variationKey: valueOr(options.VariationKey, "variation"), + globalVariablePrefix: valueOr(options.GlobalVariablePrefix, "variable"), + onTrack: options.OnTrack, + } + if strings.Contains(p.globalVariablePrefix, p.keySeparator) { + panic("globalVariablePrefix cannot contain keySeparator") } if options.Featurevisor != nil { p.featurevisor = options.Featurevisor @@ -150,7 +156,18 @@ func (p *Provider) resolve(flag string, defaultValue any, flatCtx of.FlattenedCo var evaluation featurevisor.Evaluation var value any - if selector == "" { + if featureKey == p.globalVariablePrefix && selector != "" { + evaluation = p.featurevisor.EvaluateGlobalVariable(selector, context, featurevisor.OverrideOptions{}) + value = evaluation.VariableValue + if evaluation.GlobalVariable != nil && evaluation.GlobalVariable.Type == featurevisor.VariableTypeJSON { + if raw, ok := value.(string); ok { + var parsed any + if json.Unmarshal([]byte(raw), &parsed) == nil { + value = parsed + } + } + } + } else if selector == "" { if expected != "boolean" { return defaultValue, typeMismatch(flag, expected) } @@ -192,7 +209,10 @@ func (p *Provider) resolve(flag string, defaultValue any, flatCtx of.FlattenedCo } func detailFor(e featurevisor.Evaluation, fv *featurevisor.Featurevisor) of.ProviderResolutionDetail { - metadata := of.FlagMetadata{"featureKey": string(e.FeatureKey), "featurevisorReason": string(e.Reason), "schemaVersion": fv.GetSchemaVersion()} + metadata := of.FlagMetadata{"featurevisorReason": string(e.Reason), "schemaVersion": fv.GetSchemaVersion()} + if e.FeatureKey != "" { + metadata["featureKey"] = string(e.FeatureKey) + } if revision := fv.GetRevision(); revision != "" { metadata["revision"] = revision } @@ -214,6 +234,9 @@ func detailFor(e featurevisor.Evaluation, fv *featurevisor.Featurevisor) of.Prov if e.VariableOverrideIndex != nil { metadata["variableOverrideIndex"] = *e.VariableOverrideIndex } + if e.VariableOverrideKey != nil { + metadata["variableOverrideKey"] = *e.VariableOverrideKey + } detail := of.ProviderResolutionDetail{Reason: reasonFor(e.Reason), FlagMetadata: metadata} if e.VariationValue != nil { detail.Variant = string(*e.VariationValue) @@ -224,7 +247,11 @@ func detailFor(e featurevisor.Evaluation, fv *featurevisor.Featurevisor) of.Prov case featurevisor.EvaluationReasonFeatureNotFound: detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Feature %q was not found", e.FeatureKey)) case featurevisor.EvaluationReasonVariableNotFound: - detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Variable %q was not found for feature %q", valueOrPointer(e.VariableKey), e.FeatureKey)) + if e.FeatureKey == "" { + detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Variable %q was not found", valueOrPointer(e.VariableKey))) + } else { + detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Variable %q was not found for feature %q", valueOrPointer(e.VariableKey), e.FeatureKey)) + } case featurevisor.EvaluationReasonNoVariations: detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Feature %q has no variations", e.FeatureKey)) case featurevisor.EvaluationReasonError: @@ -243,6 +270,8 @@ func reasonFor(reason featurevisor.EvaluationReason) of.Reason { return of.SplitReason case featurevisor.EvaluationReasonDisabled, featurevisor.EvaluationReasonVariationDisabled, featurevisor.EvaluationReasonVariableDisabled: return of.DisabledReason + case featurevisor.EvaluationReasonRequiredFeaturesUnmet: + return of.DisabledReason default: return of.DefaultReason } diff --git a/openfeature/provider_test.go b/openfeature/provider_test.go index e785d63..869c901 100644 --- a/openfeature/provider_test.go +++ b/openfeature/provider_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - featurevisor "github.com/featurevisor/featurevisor-go/v2" + featurevisor "github.com/featurevisor/featurevisor-go/v3" of "github.com/open-feature/go-sdk/openfeature" ) @@ -27,7 +27,8 @@ const testDatafile = `{ "traffic":[{"key":"all","segments":"*","percentage":100000,"variation":"on"}] }, "empty":{"bucketBy":"userId","variations":[],"traffic":[{"key":"all","segments":"*","percentage":100000,"allocation":[]}]} - } + }, + "variables":{"supportEmail":{"type":"string","defaultValue":"support@example.com"},"settings":{"type":"object","defaultValue":{"enabled":true}}} }` func newTestProvider(options ...func(*Options)) *Provider { @@ -69,6 +70,15 @@ func TestProviderResolvesEveryType(t *testing.T) { if result := p.ObjectEvaluation(context.Background(), "checkout:json", map[string]any{}, ctx); result.Value.(map[string]any)["nested"] != true { t.Fatalf("unexpected json: %#v", result) } + if result := p.StringEvaluation(context.Background(), "variable:supportEmail", "fallback", ctx); result.Value != "support@example.com" || result.FlagMetadata["featureKey"] != nil { + t.Fatalf("unexpected global string: %#v", result) + } + if result := p.ObjectEvaluation(context.Background(), "variable:settings", map[string]any{}, ctx); result.Value.(map[string]any)["enabled"] != true { + t.Fatalf("unexpected global object: %#v", result) + } + if result := p.StringEvaluation(context.Background(), "checkout:variable", "fallback", ctx); result.ResolutionDetail().ErrorCode != of.FlagNotFoundCode { + t.Fatalf("feature variable selector collided with global prefix: %#v", result) + } } func TestProviderErrorsGrammarTrackingAndLifecycle(t *testing.T) { diff --git a/public_api_test.go b/public_api_test.go index 2f1fc09..1e2a72b 100644 --- a/public_api_test.go +++ b/public_api_test.go @@ -3,7 +3,7 @@ package featurevisor_test import ( "testing" - featurevisor "github.com/featurevisor/featurevisor-go/v2" + featurevisor "github.com/featurevisor/featurevisor-go/v3" ) func TestPublicAPIContractsCompileForExternalConsumers(t *testing.T) { diff --git a/sdk_types.go b/sdk_types.go index 2b8e7ad..d519051 100644 --- a/sdk_types.go +++ b/sdk_types.go @@ -139,11 +139,12 @@ type Context map[string]interface{} */ // DatafileContent represents the content of a datafile type DatafileContent struct { - SchemaVersion string `json:"schemaVersion"` - Revision string `json:"revision"` - FeaturevisorVersion string `json:"featurevisorVersion,omitempty"` - Segments map[SegmentKey]Segment `json:"segments"` - Features map[FeatureKey]Feature `json:"features"` + SchemaVersion string `json:"schemaVersion"` + Revision string `json:"revision"` + FeaturevisorVersion string `json:"featurevisorVersion,omitempty"` + Segments map[SegmentKey]Segment `json:"segments"` + Features map[FeatureKey]Feature `json:"features"` + Variables map[GlobalVariableKey]GlobalVariable `json:"variables,omitempty"` } // FromJSON parses a JSON string and returns a DatafileContent @@ -172,6 +173,7 @@ type Feature struct { Hash *string `json:"hash,omitempty"` Deprecated *bool `json:"deprecated,omitempty"` Required []Required `json:"required,omitempty"` + RequiredFeatures []Required `json:"requiredFeatures,omitempty"` VariablesSchema map[VariableKey]VariableSchema `json:"variablesSchema,omitempty"` DisabledVariationValue *VariationValue `json:"disabledVariationValue,omitempty"` Variations []Variation `json:"variations,omitempty"` @@ -211,12 +213,25 @@ type EvaluatedFeatures map[FeatureKey]EvaluatedFeature // StickyFeatures represents sticky features type StickyFeatures = EvaluatedFeatures +// StickyVariables stores sticky values for global variables. +type StickyVariables map[GlobalVariableKey]VariableValue + +// EvaluatedVariables stores evaluated global variable values. +type EvaluatedVariables map[GlobalVariableKey]VariableValue + // RequiredWithVariation represents a required feature with variation type RequiredWithVariation struct { Key FeatureKey `json:"key"` Variation VariationValue `json:"variation"` } +// RequiredFeature describes the canonical requiredFeatures object form. +type RequiredFeature struct { + Feature FeatureKey `json:"feature"` + Enabled *bool `json:"enabled,omitempty"` + Variation *VariationValue `json:"variation,omitempty"` +} + // Required represents a required feature type Required interface{} @@ -335,6 +350,9 @@ type Traffic struct { // VariableKey represents the key of a variable type VariableKey = string +// GlobalVariableKey represents an independently evaluated variable key. +type GlobalVariableKey = string + // VariableType represents the type of a variable type VariableType string @@ -395,9 +413,24 @@ type VariableOverrideConditions struct { // VariableOverride represents a variable override type VariableOverride struct { - Value VariableValue `json:"value"` - Conditions interface{} `json:"conditions,omitempty"` // Condition | Condition[] - Segments interface{} `json:"segments,omitempty"` // GroupSegment | GroupSegment[] + Key *string `json:"key,omitempty"` + KeyPath []string `json:"keyPath,omitempty"` + Value VariableValue `json:"value"` + Conditions interface{} `json:"conditions,omitempty"` // Condition | Condition[] + Segments interface{} `json:"segments,omitempty"` // GroupSegment | GroupSegment[] + RequiredFeatures []Required `json:"requiredFeatures,omitempty"` +} + +// GlobalVariable represents an independently evaluated variable in a datafile. +type GlobalVariable struct { + Hash *string `json:"hash,omitempty"` + Deprecated *bool `json:"deprecated,omitempty"` + Type VariableType `json:"type,omitempty"` + DefaultValue VariableValue `json:"defaultValue"` + DisabledValue VariableValue `json:"disabledValue,omitempty"` + UseDefaultWhenDisabled bool `json:"useDefaultWhenDisabled,omitempty"` + RequiredFeatures []Required `json:"requiredFeatures,omitempty"` + Overrides []VariableOverride `json:"overrides,omitempty"` } // VariableSchema represents the schema of a variable From d8f366de0449eb8ba7f9ed1791f4e02c3f9761f6 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 29 Aug 2026 01:03:59 +0200 Subject: [PATCH 2/3] conformance --- README.md | 5 +- child.go | 89 +++++++++++++++----------------- cmd/commands/test.go | 4 +- conformance/sdk-v3.json | 51 +++++++++++++++++- conformance_test.go | 4 +- emitter.go | 1 - emitter_test.go | 26 +++++----- evaluate.go | 68 +++++++++++++++++------- evaluation.go | 1 + evaluation_data_provider.go | 6 +-- evaluation_data_provider_test.go | 2 +- events.go | 4 +- instance.go | 51 ++++++------------ instance_sticky_features_test.go | 8 +-- instance_test.go | 16 +++--- sdk_types.go | 36 +++++++++++++ top_vars_alignment_test.go | 88 +++++++++++++++++++++++++++++++ 17 files changed, 319 insertions(+), 141 deletions(-) create mode 100644 top_vars_alignment_test.go diff --git a/README.md b/README.md index f40c15c..a7cf2c1 100644 --- a/README.md +++ b/README.md @@ -657,6 +657,8 @@ And optionally these properties depending on whether you are evaluating a featur Modules allow you to intercept the evaluation process and customize it further as per your needs. +For feature evaluations, all `Before` callbacks run in registration order, followed by all `BeforeEvaluation` callbacks. After evaluation and caller defaults, all `AfterEvaluation` callbacks run, followed by all `After` callbacks. Global variable evaluations use only `BeforeEvaluation` and `AfterEvaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. + ### Defining a module A module is a simple struct with a recommended unique `Name` and optional functions: @@ -779,7 +781,7 @@ variableValue := childF.GetVariable("my_feature", "my_variable") Similar to parent SDK, child instances also support several additional methods: - `SetContext` -- `SetSticky` +- `SetStickyFeatures` - `EvaluateFlag` - `IsEnabled` - `EvaluateVariation` @@ -795,7 +797,6 @@ Similar to parent SDK, child instances also support several additional methods: - `GetVariableObject` - `GetVariableObjectInto` - `GetVariableJSON` -- `GetAllEvaluations` - `On` - `Close` diff --git a/child.go b/child.go index 9ce1087..7b9f1a5 100644 --- a/child.go +++ b/child.go @@ -39,7 +39,7 @@ func newFeaturevisorChild(options childOptions) *FeaturevisorChild { // On adds an event listener func (c *FeaturevisorChild) On(eventName EventName, callback EventCallback) Unsubscribe { - if eventName == EventNameContextSet || eventName == EventNameStickySet || eventName == EventNameStickyFeaturesSet || eventName == EventNameStickyVariablesSet { + if eventName == EventNameContextSet || eventName == EventNameStickyFeaturesSet || eventName == EventNameStickyVariablesSet { return c.emitter.On(eventName, callback) } @@ -70,7 +70,26 @@ func (c *FeaturevisorChild) On(eventName EventName, callback EventCallback) Unsu // SetStickyFeatures sets sticky feature evaluations on the child. func (c *FeaturevisorChild) SetStickyFeatures(sticky StickyFeatures, replace ...bool) { - c.SetSticky(sticky, replace...) + replaceValue := len(replace) > 0 && replace[0] + previousStickyFeatures := StickyFeatures{} + if c.sticky != nil { + previousStickyFeatures = *c.sticky + } + if replaceValue { + c.sticky = &sticky + } else { + newSticky := StickyFeatures{} + if c.sticky != nil { + for key, value := range *c.sticky { + newSticky[key] = value + } + } + for key, value := range sticky { + newSticky[key] = value + } + c.sticky = &newSticky + } + c.emitter.Trigger(EventNameStickyFeaturesSet, EventDetails(getParamsForStickyFeaturesSetEvent(previousStickyFeatures, *c.sticky, replaceValue))) } // SetStickyVariables sets sticky global variable values on the child. @@ -87,7 +106,6 @@ func (c *FeaturevisorChild) SetStickyVariables(sticky StickyVariables, replace . } c.stickyVariables = &next c.emitter.Trigger(EventNameStickyVariablesSet, EventDetails{"variables": mapKeys(next), "replaced": replaceValue}) - c.emitter.Trigger(EventNameStickySet, EventDetails{"features": []string{}, "variables": mapKeys(next), "replaced": replaceValue}) } // Close closes child instance listeners @@ -134,38 +152,6 @@ func (c *FeaturevisorChild) GetContext(context Context) Context { return c.parent.GetContext(merged) } -// SetSticky sets sticky features -func (c *FeaturevisorChild) SetSticky(sticky StickyFeatures, replace ...bool) { - replaceValue := false - if len(replace) > 0 { - replaceValue = replace[0] - } - - previousStickyFeatures := StickyFeatures{} - if c.sticky != nil { - previousStickyFeatures = *c.sticky - } - - if replaceValue { - c.sticky = &sticky - } else { - newSticky := StickyFeatures{} - if c.sticky != nil { - newSticky = *c.sticky - } - // Merge sticky features - for key, value := range sticky { - newSticky[key] = value - } - c.sticky = &newSticky - } - - params := getParamsForStickySetEvent(previousStickyFeatures, *c.sticky, replaceValue) - - c.emitter.Trigger(EventNameStickySet, EventDetails(params)) - c.emitter.Trigger(EventNameStickyFeaturesSet, EventDetails(params)) -} - // getEvaluationDependencies gets evaluation dependencies func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options OverrideOptions) evaluateDependencies { var sticky *StickyFeatures @@ -210,11 +196,7 @@ func (c *FeaturevisorChild) EvaluateGlobalVariable(variableKey string, args ...i // GetGlobalVariable gets an independently defined variable on the child. func (c *FeaturevisorChild) GetGlobalVariable(variableKey string, args ...interface{}) VariableValue { - evaluation := c.EvaluateGlobalVariable(variableKey, args...) - if evaluation.VariableValue == nil { - return nil - } - return evaluation.VariableValue + return getGlobalVariableValue(c.EvaluateGlobalVariable(variableKey, args...)) } func (c *FeaturevisorChild) GetGlobalVariableBoolean(variableKey string, args ...interface{}) *bool { @@ -273,6 +255,24 @@ func (c *FeaturevisorChild) GetGlobalVariableJSON(variableKey string, args ...in return c.GetGlobalVariable(variableKey, args...) } +// GetGlobalVariableArrayInto decodes an array global variable into out. +func (c *FeaturevisorChild) GetGlobalVariableArrayInto(variableKey string, args ...interface{}) error { + context, options, out, err := parseVariableIntoArgs(args...) + if err != nil { + return err + } + value := c.GetGlobalVariable(variableKey, context, options) + if value == nil { + return fmt.Errorf("global variable %q is unavailable", variableKey) + } + return decodeInto(value, out) +} + +// GetGlobalVariableObjectInto decodes an object global variable into out. +func (c *FeaturevisorChild) GetGlobalVariableObjectInto(variableKey string, args ...interface{}) error { + return c.GetGlobalVariableArrayInto(variableKey, args...) +} + // GetVariableEvaluations evaluates a global variable snapshot on the child. func (c *FeaturevisorChild) GetVariableEvaluations(context Context, variableKeys []string, options OverrideOptions) EvaluatedVariables { result := EvaluatedVariables{} @@ -286,11 +286,6 @@ func (c *FeaturevisorChild) GetVariableEvaluations(context Context, variableKeys return result } -// GetFeatureEvaluations evaluates a feature snapshot on the child. -func (c *FeaturevisorChild) GetFeatureEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { - return c.GetAllEvaluations(context, featureKeys, options) -} - func (c *FeaturevisorChild) evaluateFlag(featureKey string, context Context, options OverrideOptions) Evaluation { return evaluateWithModules(EvaluateOptions{ evaluateParams: evaluateParams{ @@ -618,8 +613,8 @@ func (c *FeaturevisorChild) GetVariableObjectInto(featureKey string, variableKey return decodeInto(objectValue, out) } -// GetAllEvaluations gets all evaluations for features -func (c *FeaturevisorChild) GetAllEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { +// GetFeatureEvaluations evaluates a feature snapshot on the child. +func (c *FeaturevisorChild) GetFeatureEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { result := EvaluatedFeatures{} keys := featureKeys diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 8f2b45d..277967e 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -89,7 +89,7 @@ func RunTestFeature(assertion map[string]interface{}, featureKey string, instanc stickyFeatures[featurevisor.FeatureKey(key)] = evaluatedFeature } } - instance.SetSticky(stickyFeatures, false) + instance.SetStickyFeatures(stickyFeatures, false) } // Create override options @@ -266,7 +266,7 @@ func RunTestFeature(assertion map[string]interface{}, featureKey string, instanc stickyFeatures[featurevisor.FeatureKey(key)] = evaluatedFeature } } - childInstance.SetSticky(stickyFeatures, false) + childInstance.SetStickyFeatures(stickyFeatures, false) } childResult := RunTestFeatureChild(childMap, featureKey, childInstance, level) diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index 682a73d..d5870a5 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -787,11 +787,28 @@ "parentStickyVariables": { "setting": "parent-sticky" }, "expectedParent": { "flag": true, "setting": "parent-sticky" }, "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" } + }, + "globalJsonCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-global-json", + "segments": {}, + "features": {}, + "variables": { + "settings": { + "type": "json", + "defaultValue": "{\"enabled\":true}" + } + } + }, + "variableKey": "settings", + "expected": { "enabled": true } } }, "defaults": { "presenceBased": true, "values": ["", 0, false, null], + "explicitNullBeatsCallerDefault": true, "aggregateEvaluationPreservesEmptyVariation": true, "aggregateCase": { "datafile": { @@ -814,6 +831,38 @@ } } }, + "modulePipeline": { + "featureOrder": [ + "before:first", + "before:second", + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second", + "after:first", + "after:second" + ], + "globalOrder": [ + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second" + ], + "requiredFeaturesUseModules": true, + "transformedDefaultsAreApplied": true + }, + "lifecycle": { + "stickyFeatureEvent": "sticky_features_set", + "stickyFeatureDiagnostic": "sticky_features_set", + "stickyVariableEvent": "sticky_variables_set", + "stickyVariableDiagnostic": "sticky_variables_set", + "diagnosticBeforeEvent": true + }, + "openFeature": { + "reasonMappings": { + "required_features_unmet": "DISABLED" + } + }, "diagnosticCase": { "featureKey": "missing", "expectedLevel": "warn", diff --git a/conformance_test.go b/conformance_test.go index 8100f92..a7c7c78 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -196,7 +196,7 @@ func loadConformanceFixture(t *testing.T) conformanceFixture { func TestSDKV3ConformanceFixture(t *testing.T) { fixture := loadConformanceFixture(t) - if fixture.Version != 5 { + if fixture.Version != 6 { t.Fatalf("unexpected fixture version %d", fixture.Version) } @@ -263,7 +263,7 @@ func TestSDKV3ConformanceFixture(t *testing.T) { instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: fixture.Defaults.AggregateCase.Datafile, }) - actualDefault := instance.GetAllEvaluations( + actualDefault := instance.GetFeatureEvaluations( Context{}, nil, OverrideOptions{DefaultVariationValue: &defaultVariation}, diff --git a/emitter.go b/emitter.go index 945ffd4..4ca885c 100644 --- a/emitter.go +++ b/emitter.go @@ -11,7 +11,6 @@ type EventName string const ( EventNameDatafileSet EventName = "datafile_set" EventNameContextSet EventName = "context_set" - EventNameStickySet EventName = "sticky_set" EventNameStickyFeaturesSet EventName = "sticky_features_set" EventNameStickyVariablesSet EventName = "sticky_variables_set" EventNameError EventName = "error" diff --git a/emitter_test.go b/emitter_test.go index 66cf750..32ff869 100644 --- a/emitter_test.go +++ b/emitter_test.go @@ -9,7 +9,7 @@ func TestEventNames(t *testing.T) { eventNames := []EventName{ EventNameDatafileSet, EventNameContextSet, - EventNameStickySet, + EventNameStickyFeaturesSet, EventNameError, } @@ -127,10 +127,10 @@ func TestEmitterMultipleListeners(t *testing.T) { callback2Called = true } - emitter.On(EventNameStickySet, callback1) - emitter.On(EventNameStickySet, callback2) + emitter.On(EventNameStickyFeaturesSet, callback1) + emitter.On(EventNameStickyFeaturesSet, callback2) - emitter.Trigger(EventNameStickySet, EventDetails{"test": "value"}) + emitter.Trigger(EventNameStickyFeaturesSet, EventDetails{"test": "value"}) if !callback1Called { t.Error("First callback should have been called") @@ -140,8 +140,8 @@ func TestEmitterMultipleListeners(t *testing.T) { t.Error("Second callback should have been called") } - if emitter.GetListenerCount(EventNameStickySet) != 2 { - t.Errorf("Expected 2 listeners, got %d", emitter.GetListenerCount(EventNameStickySet)) + if emitter.GetListenerCount(EventNameStickyFeaturesSet) != 2 { + t.Errorf("Expected 2 listeners, got %d", emitter.GetListenerCount(EventNameStickyFeaturesSet)) } } @@ -150,16 +150,16 @@ func TestEmitterTriggerUsesListenerSnapshot(t *testing.T) { calls := []string{} var unsubscribeSecond Unsubscribe - emitter.On(EventNameStickySet, func(details EventDetails) { + emitter.On(EventNameStickyFeaturesSet, func(details EventDetails) { calls = append(calls, "first") unsubscribeSecond() }) - unsubscribeSecond = emitter.On(EventNameStickySet, func(details EventDetails) { + unsubscribeSecond = emitter.On(EventNameStickyFeaturesSet, func(details EventDetails) { calls = append(calls, "second") }) - emitter.Trigger(EventNameStickySet, nil) - emitter.Trigger(EventNameStickySet, nil) + emitter.Trigger(EventNameStickyFeaturesSet, nil) + emitter.Trigger(EventNameStickyFeaturesSet, nil) expected := []string{"first", "second", "first"} if len(calls) != len(expected) { @@ -418,7 +418,7 @@ func TestEmitterOriginalSpec(t *testing.T) { } // Trigger unsubscribed event - emitter.Trigger(EventNameStickySet, EventDetails{"key": "value2"}) + emitter.Trigger(EventNameStickyFeaturesSet, EventDetails{"key": "value2"}) if len(handledDetails) != 1 { t.Errorf("Expected still 1 handled detail after triggering unsubscribed event, got %d", len(handledDetails)) } @@ -437,8 +437,8 @@ func TestEmitterOriginalSpec(t *testing.T) { if emitter.GetListenerCount(EventNameContextSet) != 0 { t.Errorf("Expected 0 listeners for context_set after ClearAll, got %d", emitter.GetListenerCount(EventNameContextSet)) } - if emitter.GetListenerCount(EventNameStickySet) != 0 { - t.Errorf("Expected 0 listeners for sticky_set after ClearAll, got %d", emitter.GetListenerCount(EventNameStickySet)) + if emitter.GetListenerCount(EventNameStickyFeaturesSet) != 0 { + t.Errorf("Expected 0 listeners for sticky_features_set after ClearAll, got %d", emitter.GetListenerCount(EventNameStickyFeaturesSet)) } } diff --git a/evaluate.go b/evaluate.go index 0f19774..b5274b4 100644 --- a/evaluate.go +++ b/evaluate.go @@ -62,9 +62,11 @@ func evaluateWithModules(opts EvaluateOptions) Evaluation { // Run the legacy feature callback and the unified evaluation callback. options := opts for _, module := range modules { - if !opts.GlobalVariable && module.Before != nil { + if !options.GlobalVariable && module.Before != nil { options = module.Before(options) } + } + for _, module := range modules { if module.BeforeEvaluation != nil { options = module.BeforeEvaluation(options) } @@ -78,28 +80,31 @@ func evaluateWithModules(opts EvaluateOptions) Evaluation { } // default: variation - if opts.DefaultVariationValue != nil && + if options.DefaultVariationValue != nil && evaluation.Type == EvaluationTypeVariation && evaluation.VariationValue == nil { - evaluation.VariationValue = opts.DefaultVariationValue + evaluation.VariationValue = options.DefaultVariationValue } // default: variable - if opts.DefaultVariableValueSet && + if options.DefaultVariableValueSet && evaluation.Type == EvaluationTypeVariable && - evaluation.VariableValue == nil { - evaluation.VariableValue = opts.DefaultVariableValue + !evaluation.variableValueSet && evaluation.VariableValue == nil { + evaluation.VariableValue = options.DefaultVariableValue + evaluation.variableValueSet = true } // run after modules for _, module := range modules { - if !opts.GlobalVariable && module.After != nil { - evaluation = module.After(evaluation, options) - } if module.AfterEvaluation != nil { evaluation = module.AfterEvaluation(evaluation, options) } } + for _, module := range modules { + if !options.GlobalVariable && module.After != nil { + evaluation = module.After(evaluation, options) + } + } return evaluation } @@ -211,6 +216,13 @@ func variableOverrideIsMatched(override VariableOverride, dependencies evaluateD return matchedSelector } +func variableValueFromPointer(value *VariableValue) VariableValue { + if value == nil { + return nil + } + return *value +} + func evaluateGlobalVariable(options EvaluateOptions) Evaluation { key := GlobalVariableKey("") if options.VariableKey != nil { @@ -221,6 +233,7 @@ func evaluateGlobalVariable(options EvaluateOptions) Evaluation { if value, ok := (*options.stickyVariables)[key]; ok { base.Reason = EvaluationReasonSticky base.VariableValue = value + base.variableValueSet = true return base } } @@ -236,8 +249,10 @@ func evaluateGlobalVariable(options EvaluateOptions) Evaluation { base.Reason = EvaluationReasonRequiredFeaturesUnmet if variable.UseDefaultWhenDisabled { base.VariableValue = variable.DefaultValue - } else if variable.DisabledValue != nil { + base.variableValueSet = variable.defaultValueSet + } else if variable.disabledValueSet { base.VariableValue = variable.DisabledValue + base.variableValueSet = true } return base } @@ -245,6 +260,7 @@ func evaluateGlobalVariable(options EvaluateOptions) Evaluation { if variableOverrideIsMatched(override, options.evaluateDependencies) { base.Reason = EvaluationReasonVariableOverrideRule base.VariableValue = override.Value + base.variableValueSet = true base.VariableOverrideIndex = &index base.VariableOverrideKey = override.Key base.VariableOverridePath = override.KeyPath @@ -253,6 +269,7 @@ func evaluateGlobalVariable(options EvaluateOptions) Evaluation { } base.Reason = EvaluationReasonVariableDefault base.VariableValue = variable.DefaultValue + base.variableValueSet = variable.defaultValueSet return base } @@ -350,6 +367,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableKey: options.VariableKey, VariableValue: variableValue, } + evaluation.variableValueSet = true options.diagnosticReporter.Debug("using sticky variable", logDetails{ "evaluation": evaluation, @@ -435,17 +453,18 @@ func evaluate(options EvaluateOptions) Evaluation { if options.Type == EvaluationTypeVariable { if feature != nil && options.VariableKey != nil && feature.VariablesSchema != nil { if variableSchema, exists := feature.VariablesSchema[*options.VariableKey]; exists { - if variableSchema.DisabledValue != nil { + if variableSchema.disabledValueSet { // disabledValue: evaluation = Evaluation{ Type: options.Type, FeatureKey: options.FeatureKey, Reason: EvaluationReasonVariableDisabled, VariableKey: options.VariableKey, - VariableValue: *variableSchema.DisabledValue, + VariableValue: variableValueFromPointer(variableSchema.DisabledValue), VariableSchema: &variableSchema, Enabled: &[]bool{false}[0], } + evaluation.variableValueSet = true } else if variableSchema.UseDefaultWhenDisabled != nil && *variableSchema.UseDefaultWhenDisabled { // useDefaultWhenDisabled: true evaluation = Evaluation{ @@ -457,6 +476,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: &variableSchema, Enabled: &[]bool{false}[0], } + evaluation.variableValueSet = variableSchema.defaultValueSet } } } @@ -544,6 +564,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: variableSchema, VariableValue: variableValue, } + evaluation.variableValueSet = true options.diagnosticReporter.Debug("forced variable", logDetails{ "evaluation": evaluation, @@ -564,12 +585,15 @@ func evaluate(options EvaluateOptions) Evaluation { } if len(requiredFeatures) > 0 && !requiredFeaturesAreMatched(requiredFeatures, options.evaluateDependencies) { evaluation = Evaluation{ - Type: options.Type, - FeatureKey: options.FeatureKey, - Reason: EvaluationReasonRequired, - Required: requiredFeatures, - RequiredFeatures: requiredFeatures, - Enabled: &[]bool{false}[0], + Type: options.Type, + FeatureKey: options.FeatureKey, + Reason: EvaluationReasonRequired, + Enabled: &[]bool{false}[0], + } + if len(feature.RequiredFeatures) > 0 { + evaluation.RequiredFeatures = feature.RequiredFeatures + } else { + evaluation.Required = feature.Required } options.diagnosticReporter.Debug("required features not enabled", logDetails{ @@ -916,6 +940,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableOverrideKey: override.Key, VariableOverridePath: override.KeyPath, } + evaluation.variableValueSet = true options.diagnosticReporter.Debug("variable override from rule", logDetails{ "evaluation": evaluation, @@ -941,6 +966,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: variableSchema, VariableValue: variableValue, } + evaluation.variableValueSet = true options.diagnosticReporter.Debug("override from rule", logDetails{ "evaluation": evaluation, @@ -990,6 +1016,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableOverrideKey: override.Key, VariableOverridePath: override.KeyPath, } + evaluation.variableValueSet = true options.diagnosticReporter.Debug("variable override from variation", logDetails{ "evaluation": evaluation, @@ -1020,6 +1047,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: variableSchema, VariableValue: variableValue, } + evaluation.variableValueSet = true options.diagnosticReporter.Debug("allocated variable", logDetails{ "evaluation": evaluation, @@ -1033,7 +1061,7 @@ func evaluate(options EvaluateOptions) Evaluation { } // Check for default value from variable schema - if variableSchema.DefaultValue != nil { + if variableSchema.defaultValueSet { evaluation = Evaluation{ Type: options.Type, FeatureKey: options.FeatureKey, @@ -1044,6 +1072,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: variableSchema, VariableValue: variableSchema.DefaultValue, } + evaluation.variableValueSet = variableSchema.defaultValueSet options.diagnosticReporter.Debug("using default value", logDetails{ "evaluation": evaluation, @@ -1100,6 +1129,7 @@ func evaluate(options EvaluateOptions) Evaluation { VariableSchema: variableSchema, VariableValue: variableSchema.DefaultValue, } + evaluation.variableValueSet = variableSchema.defaultValueSet options.diagnosticReporter.Debug("using default value", logDetails{ "evaluation": evaluation, diff --git a/evaluation.go b/evaluation.go index 4dc02af..2e5ffda 100644 --- a/evaluation.go +++ b/evaluation.go @@ -73,4 +73,5 @@ type Evaluation struct { VariableOverrideIndex *int `json:"variableOverrideIndex,omitempty"` VariableOverrideKey *string `json:"variableOverrideKey,omitempty"` VariableOverridePath []string `json:"variableOverridePath,omitempty"` + variableValueSet bool } diff --git a/evaluation_data_provider.go b/evaluation_data_provider.go index 79dc011..49bff05 100644 --- a/evaluation_data_provider.go +++ b/evaluation_data_provider.go @@ -8,7 +8,7 @@ import ( "sync" ) -// instanceEvaluationDataProviderOptions contains options for creating a datafile reader +// instanceEvaluationDataProviderOptions contains options for creating an evaluation data provider. type instanceEvaluationDataProviderOptions struct { Datafile DatafileContent diagnosticReporter *diagnosticReporter @@ -32,7 +32,7 @@ type instanceEvaluationDataProvider struct { regexCacheMu sync.RWMutex } -// newInstanceEvaluationDataProvider creates a new datafile reader instance +// newInstanceEvaluationDataProvider creates an evaluation data provider. func newInstanceEvaluationDataProvider(options instanceEvaluationDataProviderOptions) *instanceEvaluationDataProvider { return &instanceEvaluationDataProvider{ schemaVersion: options.Datafile.SchemaVersion, @@ -47,7 +47,7 @@ func newInstanceEvaluationDataProvider(options instanceEvaluationDataProviderOpt // AllConditionsAreMatched checks whether a condition tree matches the given context. // It mirrors the JavaScript SDK's narrow root helper export without exposing the -// internal datafile reader implementation. +// internal evaluation data provider implementation. func AllConditionsAreMatched(conditions Condition, context Context) bool { reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: DatafileContent{ diff --git a/evaluation_data_provider_test.go b/evaluation_data_provider_test.go index 6c39e65..6661f7c 100644 --- a/evaluation_data_provider_test.go +++ b/evaluation_data_provider_test.go @@ -208,7 +208,7 @@ func TestInstanceEvaluationDataProviderAllConditionsAreMatched(t *testing.T) { } } -// TestInstanceEvaluationDataProviderComprehensive tests comprehensive datafile reader functionality +// TestInstanceEvaluationDataProviderComprehensive tests the complete evaluation data provider. func TestInstanceEvaluationDataProviderComprehensive(t *testing.T) { diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) diff --git a/events.go b/events.go index 14f5ed4..cd47dae 100644 --- a/events.go +++ b/events.go @@ -252,8 +252,8 @@ func getParamsForDatafileSetEvent( } } -// getParamsForStickySetEvent gets parameters for sticky set event -func getParamsForStickySetEvent(previousStickyFeatures StickyFeatures, newStickyFeatures StickyFeatures, replace bool) logDetails { +// getParamsForStickyFeaturesSetEvent gets parameters for a sticky features set event. +func getParamsForStickyFeaturesSetEvent(previousStickyFeatures StickyFeatures, newStickyFeatures StickyFeatures, replace bool) logDetails { keysBefore := make([]string, 0, len(previousStickyFeatures)) for key := range previousStickyFeatures { keysBefore = append(keysBefore, string(key)) diff --git a/instance.go b/instance.go index e3407af..884fe46 100644 --- a/instance.go +++ b/instance.go @@ -17,7 +17,6 @@ type OverrideOptions struct { // SpawnOptions configures a child SDK instance. type SpawnOptions struct { - Sticky *StickyFeatures StickyFeatures *StickyFeatures StickyVariables *StickyVariables } @@ -28,7 +27,6 @@ type FeaturevisorOptions struct { Context Context LogLevel *LogLevel OnDiagnostic FeaturevisorDiagnosticHandler - Sticky *StickyFeatures StickyFeatures *StickyFeatures StickyVariables *StickyVariables Modules []*FeaturevisorModule @@ -171,13 +169,8 @@ func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { emitter: emitter, datafile: emptyDatafile, instanceEvaluationDataProvider: instanceEvaluationDataProvider, - sticky: func() *StickyFeatures { - if options.StickyFeatures != nil { - return options.StickyFeatures - } - return options.Sticky - }(), - stickyVariables: options.StickyVariables, + sticky: options.StickyFeatures, + stickyVariables: options.StickyVariables, } instance.modulesManager = newModulesManager(modulesManagerOptions{ @@ -252,8 +245,8 @@ func (i *Featurevisor) SetDatafile(datafile interface{}, replace ...bool) { i.emitter.Trigger(EventNameDatafileSet, EventDetails(details)) } -// SetSticky sets sticky features -func (i *Featurevisor) SetSticky(sticky StickyFeatures, replace ...bool) { +// SetStickyFeatures sets sticky feature evaluations. +func (i *Featurevisor) SetStickyFeatures(sticky StickyFeatures, replace ...bool) { if i.closed { return } @@ -282,23 +275,17 @@ func (i *Featurevisor) SetSticky(sticky StickyFeatures, replace ...bool) { i.sticky = &newSticky } - params := getParamsForStickySetEvent(previousStickyFeatures, *i.sticky, replaceValue) + params := getParamsForStickyFeaturesSetEvent(previousStickyFeatures, *i.sticky, replaceValue) i.reportDiagnostic(FeaturevisorDiagnostic{ Level: LogLevelInfo, - Code: "sticky_set", + Code: "sticky_features_set", Message: "Sticky features set", Details: params, }, nil) - i.emitter.Trigger(EventNameStickySet, EventDetails(params)) i.emitter.Trigger(EventNameStickyFeaturesSet, EventDetails(params)) } -// SetStickyFeatures sets sticky feature evaluations. -func (i *Featurevisor) SetStickyFeatures(sticky StickyFeatures, replace ...bool) { - i.SetSticky(sticky, replace...) -} - // SetStickyVariables sets sticky global variable values. func (i *Featurevisor) SetStickyVariables(sticky StickyVariables, replace ...bool) { if i.closed { @@ -338,7 +325,6 @@ func (i *Featurevisor) SetStickyVariables(sticky StickyVariables, replace ...boo details := logDetails{"variables": keys, "replaced": replaceValue} i.reportDiagnostic(FeaturevisorDiagnostic{Level: LogLevelInfo, Code: "sticky_variables_set", Message: "Sticky variables set", Details: details}, nil) i.emitter.Trigger(EventNameStickyVariablesSet, EventDetails(details)) - i.emitter.Trigger(EventNameStickySet, EventDetails{"features": []string{}, "variables": keys, "replaced": replaceValue}) } // GetRevision returns the revision @@ -608,14 +594,9 @@ func (i *Featurevisor) Spawn(args ...interface{}) *FeaturevisorChild { } return newFeaturevisorChild(childOptions{ - Parent: i, - Context: i.GetContext(contextValue), - Sticky: func() *StickyFeatures { - if optionsValue.StickyFeatures != nil { - return optionsValue.StickyFeatures - } - return optionsValue.Sticky - }(), + Parent: i, + Context: i.GetContext(contextValue), + Sticky: optionsValue.StickyFeatures, StickyVariables: optionsValue.StickyVariables, }) } @@ -659,7 +640,10 @@ func (i *Featurevisor) EvaluateGlobalVariable(variableKey string, args ...interf // GetGlobalVariable gets an independently defined variable. func (i *Featurevisor) GetGlobalVariable(variableKey string, args ...interface{}) VariableValue { - evaluation := i.EvaluateGlobalVariable(variableKey, args...) + return getGlobalVariableValue(i.EvaluateGlobalVariable(variableKey, args...)) +} + +func getGlobalVariableValue(evaluation Evaluation) VariableValue { if evaluation.VariableValue == nil { return nil } @@ -1073,8 +1057,8 @@ func (i *Featurevisor) GetVariableObjectInto(featureKey string, variableKey stri return decodeInto(objectValue, out) } -// GetAllEvaluations gets all evaluations for features -func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { +// GetFeatureEvaluations evaluates a feature snapshot. +func (i *Featurevisor) GetFeatureEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { result := EvaluatedFeatures{} keys := featureKeys @@ -1121,11 +1105,6 @@ func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, return result } -// GetFeatureEvaluations evaluates a feature snapshot. -func (i *Featurevisor) GetFeatureEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { - return i.GetAllEvaluations(context, featureKeys, options) -} - // GetVariableEvaluations evaluates a global variable snapshot. func (i *Featurevisor) GetVariableEvaluations(context Context, variableKeys []string, options OverrideOptions) EvaluatedVariables { result := EvaluatedVariables{} diff --git a/instance_sticky_features_test.go b/instance_sticky_features_test.go index c6fdacc..8e92cb2 100644 --- a/instance_sticky_features_test.go +++ b/instance_sticky_features_test.go @@ -41,7 +41,7 @@ func TestStickyFeaturesInitialization(t *testing.T) { // Create instance with sticky features and datafile instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafileContent, - Sticky: &StickyFeatures{ + StickyFeatures: &StickyFeatures{ "test": EvaluatedFeature{ Enabled: true, Variation: stringPtr("control"), @@ -111,7 +111,7 @@ func TestStickyFeaturesInitialization(t *testing.T) { } // Unset sticky features - instance.SetSticky(StickyFeatures{}, true) + instance.SetStickyFeatures(StickyFeatures{}, true) // Should now be treatment (from datafile) variation = instance.GetVariation("test", context, OverrideOptions{}) @@ -126,11 +126,11 @@ func TestSetStickyVariadicSignature(t *testing.T) { // Test calling without replace parameter (should default to false) sticky1 := StickyFeatures{"test1": EvaluatedFeature{Enabled: true}} - instance.SetSticky(sticky1) + instance.SetStickyFeatures(sticky1) // Test calling with replace parameter sticky2 := StickyFeatures{"test2": EvaluatedFeature{Enabled: false}} - instance.SetSticky(sticky2, true) + instance.SetStickyFeatures(sticky2, true) // Verify that the second call replaced the first (since replace=true) if instance.sticky == nil { diff --git a/instance_test.go b/instance_test.go index b3a3044..1c4450a 100644 --- a/instance_test.go +++ b/instance_test.go @@ -777,7 +777,7 @@ func TestSetDatafileMergesByDefaultAndReplacesWhenRequested(t *testing.T) { } } -func TestGetAllEvaluations(t *testing.T) { +func TestGetFeatureEvaluations(t *testing.T) { jsonDatafile := `{ "schemaVersion": "2", "revision": "1.0", @@ -876,9 +876,9 @@ func TestGetAllEvaluations(t *testing.T) { context := Context{"userId": "123"} - // Test GetAllEvaluations with specific feature keys + // Test GetFeatureEvaluations with specific feature keys featureKeys := []string{"test", "anotherTest"} - evaluatedFeatures := instance.GetAllEvaluations(context, featureKeys, OverrideOptions{}) + evaluatedFeatures := instance.GetFeatureEvaluations(context, featureKeys, OverrideOptions{}) // Validate test feature evaluation testFeature, exists := evaluatedFeatures["test"] @@ -985,8 +985,8 @@ func TestGetAllEvaluations(t *testing.T) { t.Errorf("Expected 'anotherTest' feature to not have variables, got '%v'", anotherTestFeature.Variables) } - // Test GetAllEvaluations with empty feature keys (should return all features) - allEvaluatedFeatures := instance.GetAllEvaluations(context, []string{}, OverrideOptions{}) + // Test GetFeatureEvaluations with empty feature keys (should return all features) + allEvaluatedFeatures := instance.GetFeatureEvaluations(context, []string{}, OverrideOptions{}) // Should contain both features if _, exists := allEvaluatedFeatures["test"]; !exists { @@ -998,7 +998,7 @@ func TestGetAllEvaluations(t *testing.T) { } // Test with non-existent feature keys - nonExistentFeatures := instance.GetAllEvaluations(context, []string{"nonExistent"}, OverrideOptions{}) + nonExistentFeatures := instance.GetFeatureEvaluations(context, []string{"nonExistent"}, OverrideOptions{}) if len(nonExistentFeatures) != 1 { t.Errorf("Expected 1 feature for non-existent key, got %d features", len(nonExistentFeatures)) } @@ -1027,7 +1027,7 @@ func TestLifecycleMutationsReportDiagnostics(t *testing.T) { Segments: map[SegmentKey]Segment{}, Features: map[FeatureKey]Feature{}, }) - instance.SetSticky(StickyFeatures{"test": EvaluatedFeature{Enabled: true}}) + instance.SetStickyFeatures(StickyFeatures{"test": EvaluatedFeature{Enabled: true}}) instance.SetContext(Context{"country": "nl"}) codes := map[string]bool{} @@ -1035,7 +1035,7 @@ func TestLifecycleMutationsReportDiagnostics(t *testing.T) { codes[diagnostic.Code] = true } - for _, code := range []string{"datafile_set", "sticky_set", "context_set"} { + for _, code := range []string{"datafile_set", "sticky_features_set", "context_set"} { if !codes[code] { t.Fatalf("expected %s diagnostic, got %#v", code, diagnostics) } diff --git a/sdk_types.go b/sdk_types.go index d519051..1eca40b 100644 --- a/sdk_types.go +++ b/sdk_types.go @@ -431,6 +431,24 @@ type GlobalVariable struct { UseDefaultWhenDisabled bool `json:"useDefaultWhenDisabled,omitempty"` RequiredFeatures []Required `json:"requiredFeatures,omitempty"` Overrides []VariableOverride `json:"overrides,omitempty"` + defaultValueSet bool + disabledValueSet bool +} + +func (variable *GlobalVariable) UnmarshalJSON(data []byte) error { + type alias GlobalVariable + var decoded alias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *variable = GlobalVariable(decoded) + _, variable.defaultValueSet = fields["defaultValue"] + _, variable.disabledValueSet = fields["disabledValue"] + return nil } // VariableSchema represents the schema of a variable @@ -458,6 +476,24 @@ type VariableSchema struct { Description *string `json:"description,omitempty"` UseDefaultWhenDisabled *bool `json:"useDefaultWhenDisabled,omitempty"` DisabledValue *VariableValue `json:"disabledValue,omitempty"` + defaultValueSet bool + disabledValueSet bool +} + +func (schema *VariableSchema) UnmarshalJSON(data []byte) error { + type alias VariableSchema + var decoded alias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *schema = VariableSchema(decoded) + _, schema.defaultValueSet = fields["defaultValue"] + _, schema.disabledValueSet = fields["disabledValue"] + return nil } /** diff --git a/top_vars_alignment_test.go b/top_vars_alignment_test.go new file mode 100644 index 0000000..45d6553 --- /dev/null +++ b/top_vars_alignment_test.go @@ -0,0 +1,88 @@ +package featurevisor + +import ( + "reflect" + "testing" +) + +func alignmentDatafile(t *testing.T, raw string) DatafileContent { + t.Helper() + var datafile DatafileContent + if err := datafile.FromJSON(raw); err != nil { + t.Fatal(err) + } + return datafile +} + +func TestModulePipelineUsesCanonicalPhaseOrder(t *testing.T) { + order := []string{} + module := func(name string) *FeaturevisorModule { + return &FeaturevisorModule{ + Name: name, + Before: func(options EvaluateOptions) EvaluateOptions { order = append(order, "before:"+name); return options }, + BeforeEvaluation: func(options EvaluateOptions) EvaluateOptions { + order = append(order, "beforeEvaluation:"+name) + return options + }, + AfterEvaluation: func(evaluation Evaluation, options EvaluateOptions) Evaluation { + order = append(order, "afterEvaluation:"+name) + return evaluation + }, + After: func(evaluation Evaluation, options EvaluateOptions) Evaluation { + order = append(order, "after:"+name) + return evaluation + }, + } + } + f := CreateFeaturevisor(FeaturevisorOptions{Modules: []*FeaturevisorModule{module("first"), module("second")}}) + f.EvaluateFlag("missing", Context{}, OverrideOptions{}) + expected := []string{"before:first", "before:second", "beforeEvaluation:first", "beforeEvaluation:second", "afterEvaluation:first", "afterEvaluation:second", "after:first", "after:second"} + if !reflect.DeepEqual(order, expected) { + t.Fatalf("unexpected module order: %#v", order) + } +} + +func TestModulesApplyToRequiredFeaturesAndTransformedDefaults(t *testing.T) { + datafile := alignmentDatafile(t, `{"schemaVersion":"2","revision":"modules","segments":{"allowed":{"conditions":{"attribute":"allow","operator":"equals","value":true}}},"features":{"required":{"bucketBy":"userId","traffic":[{"key":"all","segments":"allowed","percentage":100000}]},"dependent":{"bucketBy":"userId","requiredFeatures":["required"],"traffic":[{"key":"all","segments":"*","percentage":100000}]}},"variables":{}}`) + module := &FeaturevisorModule{Name: "required-context", BeforeEvaluation: func(options EvaluateOptions) EvaluateOptions { + if options.FeatureKey == "required" { + options.Context["allow"] = true + } + if options.Type == EvaluationTypeVariable { + options.DefaultVariableValue = "module-default" + options.DefaultVariableValueSet = true + } + return options + }} + f := CreateFeaturevisor(FeaturevisorOptions{Datafile: datafile, Modules: []*FeaturevisorModule{module}}) + if !f.IsEnabled("dependent", Context{"userId": "u"}, OverrideOptions{}) { + t.Fatal("required feature did not use the module pipeline") + } + if value := f.GetGlobalVariable("missing"); value != "module-default" { + t.Fatalf("module default was ignored: %#v", value) + } +} + +func TestExplicitNullBeatsCallerDefaultsAndChildNormalizesGlobalJSON(t *testing.T) { + datafile := alignmentDatafile(t, `{"schemaVersion":"2","revision":"nulls","segments":{},"features":{"feature":{"bucketBy":"userId","variablesSchema":{"nullable":{"type":"json","defaultValue":null}},"traffic":[{"key":"all","segments":"*","percentage":100000}]}},"variables":{"nullable":{"type":"json","defaultValue":null},"settings":{"type":"json","defaultValue":"{\"enabled\":true}"}}}`) + f := CreateFeaturevisor(FeaturevisorOptions{Datafile: datafile}) + options := OverrideOptions{DefaultVariableValue: "caller", DefaultVariableValueSet: true} + featureEvaluation := f.EvaluateVariable("feature", "nullable", Context{"userId": "u"}, options) + if !featureEvaluation.variableValueSet || featureEvaluation.VariableValue != nil { + t.Fatalf("feature null was not preserved: %#v", featureEvaluation) + } + globalEvaluation := f.EvaluateGlobalVariable("nullable", options) + if !globalEvaluation.variableValueSet || globalEvaluation.VariableValue != nil { + t.Fatalf("global null was not preserved: %#v", globalEvaluation) + } + child := f.Spawn() + if value := child.GetGlobalVariable("settings"); !reflect.DeepEqual(value, map[string]interface{}{"enabled": true}) { + t.Fatalf("child global JSON was not normalized: %#v", value) + } + var decoded struct { + Enabled bool `json:"enabled"` + } + if err := child.GetGlobalVariableObjectInto("settings", &decoded); err != nil || !decoded.Enabled { + t.Fatalf("child object decoder failed: %#v, %v", decoded, err) + } +} From c28403fe9f784b3e785dccae27538465849e6bb0 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 29 Aug 2026 01:59:27 +0200 Subject: [PATCH 3/3] release preparation --- .github/workflows/checks.yml | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e721a85..b08a64c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -57,32 +57,25 @@ jobs: steps: - uses: actions/checkout@v7 - with: - path: featurevisor-go - - - uses: actions/checkout@v7 - with: - repository: featurevisor/featurevisor - ref: main - path: featurevisor - uses: actions/setup-go@v6 with: go-version: "1.25.x" - cache-dependency-path: featurevisor-go/go.sum + cache-dependency-path: go.sum - uses: actions/setup-node@v7 with: - node-version-file: "featurevisor/.nvmrc" - cache: npm - cache-dependency-path: featurevisor/package-lock.json + node-version-file: ".nvmrc" + package-manager-cache: false - - name: Build Featurevisor CLI - working-directory: featurevisor + - name: Set up Featurevisor example-1 project run: | - npm ci - npm run build + mkdir example-1 + cd example-1 + npx --yes @featurevisor/cli@3 init --example=1 + npm install + npx featurevisor build + npx featurevisor test --onlyFailures - name: Run example-1 through Go SDK - working-directory: featurevisor-go - run: go run ./cmd/main.go test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures + run: go run ./cmd/main.go test --projectDirectoryPath=./example-1 --onlyFailures