From 94a6243fc2d66a6b7e815e6b59b482205db44ba8 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 27 Feb 2026 22:49:41 +0100 Subject: [PATCH 1/5] setup monorepo locally --- .gitignore | 2 ++ Makefile | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/.gitignore b/.gitignore index c96a0fd..7891425 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ Thumbs.db build example-1 + +monorepo diff --git a/Makefile b/Makefile index 04ce8e8..530e47c 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: build test clean setup-monorepo update-monorepo + build: mkdir -p build go build -o build/featurevisor-go cmd/main.go @@ -7,3 +9,15 @@ test: clean: rm -rf build + +setup-monorepo: + mkdir -p monorepo + if [ ! -d "monorepo/.git" ]; then \ + git clone git@github.com:featurevisor/featurevisor.git monorepo; \ + else \ + (cd monorepo && git fetch origin main && git checkout main && git pull origin main); \ + fi + (cd monorepo && make install && make build) + +update-monorepo: + (cd monorepo && git pull origin main) From dec0f289099e66b0a2bc2d81674cd72964e5da49 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 27 Feb 2026 23:01:15 +0100 Subject: [PATCH 2/5] gitignore updated --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7891425..e9f4cb9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ build example-1 monorepo +specs From 38a3a4bd4420bf27673556f535acca9f881fcf6f Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 27 Feb 2026 23:19:01 +0100 Subject: [PATCH 3/5] sdk parity --- README.md | 19 ++ child.go | 37 ++- child_parity_test.go | 58 +++++ cmd/commands/commands.go | 4 + cmd/commands/commands_test.go | 14 + cmd/commands/test.go | 465 +++++++++++++++++++++++++++------- cmd/commands/test_types.go | 2 + events.go | 3 - events_parity_test.go | 46 ++++ helpers.go | 10 +- helpers_parity_test.go | 15 ++ instance.go | 72 ++++-- instance_api_parity_test.go | 36 +++ sdk_types.go | 49 +++- 14 files changed, 680 insertions(+), 150 deletions(-) create mode 100644 child_parity_test.go create mode 100644 cmd/commands/commands_test.go create mode 100644 events_parity_test.go create mode 100644 helpers_parity_test.go create mode 100644 instance_api_parity_test.go diff --git a/README.md b/README.md index cfc288d..50ca3b7 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,8 @@ You may also initialize the SDK without passing `datafile`, and set it later on: f.SetDatafile(datafileContent) ``` +`SetDatafile` accepts either parsed `featurevisor.DatafileContent` or a raw JSON string. + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile-set) event that you can listen and react to accordingly. @@ -699,10 +701,27 @@ go run cmd/main.go test \ --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \ --quiet|verbose \ --onlyFailures \ + --with-scopes \ + --with-tags \ --keyPattern="myFeatureKey" \ --assertionPattern="#1" ``` +`--with-scopes` and `--with-tags` match Featurevisor CLI behavior by generating and testing against scoped/tagged datafiles via `npx featurevisor build`. + +If you want to validate parity locally against the JavaScript SDK runner, you can use the bundled example project: + +```bash +cd monorepo/examples/example-1 +npx featurevisor test --with-scopes --with-tags + +# from repository root: +go run cmd/main.go test \ + --projectDirectoryPath="/absolute/path/to/featurevisor-go/monorepo/examples/example-1" \ + --with-scopes \ + --with-tags +``` + ### Benchmark Learn more about benchmarking [here](https://featurevisor.com/docs/cmd/#benchmarking). diff --git a/child.go b/child.go index bbe049d..4352923 100644 --- a/child.go +++ b/child.go @@ -12,6 +12,7 @@ type FeaturevisorChild struct { parent *Featurevisor context Context sticky *StickyFeatures + emitter *Emitter } // NewFeaturevisorChild creates a new child instance @@ -20,9 +21,24 @@ func NewFeaturevisorChild(options ChildOptions) *FeaturevisorChild { parent: options.Parent, context: options.Context, sticky: options.Sticky, + emitter: NewEmitter(), } } +// On adds an event listener +func (c *FeaturevisorChild) On(eventName EventName, callback EventCallback) Unsubscribe { + if eventName == EventNameContextSet || eventName == EventNameStickySet { + return c.emitter.On(eventName, callback) + } + + return c.parent.On(eventName, callback) +} + +// Close closes child instance listeners +func (c *FeaturevisorChild) Close() { + c.emitter.ClearAll() +} + // SetContext sets the context func (c *FeaturevisorChild) SetContext(context Context, replace ...bool) { replaceValue := false @@ -38,24 +54,24 @@ func (c *FeaturevisorChild) SetContext(context Context, replace ...bool) { c.context[key] = value } } + + c.emitter.Trigger(EventNameContextSet, EventDetails{ + "context": c.context, + "replaced": replaceValue, + }) } // GetContext returns the context func (c *FeaturevisorChild) GetContext(context Context) Context { - if context == nil { - return c.context - } - - // Merge contexts - result := Context{} + merged := Context{} for key, value := range c.context { - result[key] = value + merged[key] = value } for key, value := range context { - result[key] = value + merged[key] = value } - return result + return c.parent.GetContext(merged) } // SetSticky sets sticky features @@ -86,8 +102,7 @@ func (c *FeaturevisorChild) SetSticky(sticky StickyFeatures, replace ...bool) { params := getParamsForStickySetEvent(previousStickyFeatures, *c.sticky, replaceValue) - c.parent.logger.Info("sticky features set", params) - c.parent.emitter.Trigger(EventNameStickySet, EventDetails(params)) + c.emitter.Trigger(EventNameStickySet, EventDetails(params)) } // getEvaluationDependencies gets evaluation dependencies diff --git a/child_parity_test.go b/child_parity_test.go new file mode 100644 index 0000000..66b4e9f --- /dev/null +++ b/child_parity_test.go @@ -0,0 +1,58 @@ +package featurevisor + +import "testing" + +func TestChildEventIsolationAndProxying(t *testing.T) { + instance := CreateInstance(Options{ + Datafile: DatafileContent{ + SchemaVersion: "2", + Revision: "1", + Features: map[FeatureKey]Feature{}, + Segments: map[SegmentKey]Segment{}, + }, + }) + child := instance.Spawn() + + childContextEvents := 0 + parentContextEvents := 0 + childDatafileEvents := 0 + + childUnsubscribe := child.On(EventNameContextSet, func(details EventDetails) { + childContextEvents++ + }) + defer childUnsubscribe() + + parentUnsubscribe := instance.On(EventNameContextSet, func(details EventDetails) { + parentContextEvents++ + }) + defer parentUnsubscribe() + + datafileUnsubscribe := child.On(EventNameDatafileSet, func(details EventDetails) { + childDatafileEvents++ + }) + defer datafileUnsubscribe() + + child.SetContext(Context{"userId": "123"}) + if childContextEvents != 1 { + t.Fatalf("expected child context_set listener to be called once, got %d", childContextEvents) + } + if parentContextEvents != 0 { + t.Fatalf("expected parent context_set listener not to be called by child.setContext, got %d", parentContextEvents) + } + + instance.SetDatafile(DatafileContent{ + SchemaVersion: "2", + Revision: "2", + Features: map[FeatureKey]Feature{}, + Segments: map[SegmentKey]Segment{}, + }) + if childDatafileEvents != 1 { + t.Fatalf("expected child datafile_set listener to proxy parent event, got %d", childDatafileEvents) + } + + child.Close() + child.SetContext(Context{"country": "nl"}) + if childContextEvents != 1 { + t.Fatalf("expected child listeners to be cleared after close, got %d", childContextEvents) + } +} diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index c0c93aa..729b186 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -21,6 +21,8 @@ type CLIOptions struct { Variation bool Verbose bool Inflate int + WithScopes bool + WithTags bool ShowDatafile bool SchemaVersion string ProjectDirectoryPath string @@ -59,6 +61,8 @@ func ParseCLIOptions(args []string) CLIOptions { fs.BoolVar(&opts.Variation, "variation", false, "Variation mode") fs.BoolVar(&opts.Verbose, "verbose", false, "Verbose mode") fs.IntVar(&opts.Inflate, "inflate", 0, "Inflate mode") + fs.BoolVar(&opts.WithScopes, "with-scopes", false, "Test with scoped datafiles") + fs.BoolVar(&opts.WithTags, "with-tags", false, "Test with tagged datafiles") fs.BoolVar(&opts.ShowDatafile, "showDatafile", false, "Show datafile") fs.StringVar(&opts.SchemaVersion, "schemaVersion", "", "Schema version") fs.StringVar(&opts.ProjectDirectoryPath, "projectDirectoryPath", "", "Project directory path") diff --git a/cmd/commands/commands_test.go b/cmd/commands/commands_test.go new file mode 100644 index 0000000..ee868fb --- /dev/null +++ b/cmd/commands/commands_test.go @@ -0,0 +1,14 @@ +package commands + +import "testing" + +func TestParseCLIOptionsWithScopesAndTags(t *testing.T) { + opts := ParseCLIOptions([]string{"--with-scopes", "--with-tags"}) + + if !opts.WithScopes { + t.Fatalf("expected WithScopes to be true") + } + if !opts.WithTags { + t.Fatalf("expected WithTags to be true") + } +} diff --git a/cmd/commands/test.go b/cmd/commands/test.go index bba1129..2fb3104 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "time" @@ -579,28 +580,49 @@ func compareValues(actual, expected interface{}) bool { } // Common functions -func executeCommand(command string) string { - cmd := exec.Command("bash", "-c", command) - output, err := cmd.Output() +const noEnvironmentKey = "__no_environment__" + +func executeFeaturevisorCommand(featurevisorProjectPath string, args ...string) (string, error) { + cmdArgs := append([]string{"featurevisor"}, args...) + cmd := exec.Command("npx", cmdArgs...) + cmd.Dir = featurevisorProjectPath + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("command failed: npx %s\n%s", strings.Join(cmdArgs, " "), strings.TrimSpace(string(output))) + } + + return strings.TrimSpace(string(output)), nil +} + +func mustExecuteFeaturevisorCommand(featurevisorProjectPath string, args ...string) string { + output, err := executeFeaturevisorCommand(featurevisorProjectPath, args...) if err != nil { - return "" + fmt.Println(err.Error()) + os.Exit(1) } - return strings.TrimSpace(string(output)) + + return output } func getConfig(featurevisorProjectPath string) map[string]interface{} { fmt.Println("Getting config...") - configOutput := executeCommand(fmt.Sprintf("(cd %s && npx featurevisor config --json)", featurevisorProjectPath)) + configOutput := mustExecuteFeaturevisorCommand(featurevisorProjectPath, "config", "--json") var config map[string]interface{} - json.Unmarshal([]byte(configOutput), &config) + if err := json.Unmarshal([]byte(configOutput), &config); err != nil { + fmt.Printf("failed to parse config json: %v\n", err) + os.Exit(1) + } return config } func getSegments(featurevisorProjectPath string) map[string]interface{} { fmt.Println("Getting segments...") - segmentsOutput := executeCommand(fmt.Sprintf("(cd %s && npx featurevisor list --segments --json)", featurevisorProjectPath)) + segmentsOutput := mustExecuteFeaturevisorCommand(featurevisorProjectPath, "list", "--segments", "--json") var segments []map[string]interface{} - json.Unmarshal([]byte(segmentsOutput), &segments) + if err := json.Unmarshal([]byte(segmentsOutput), &segments); err != nil { + fmt.Printf("failed to parse segments json: %v\n", err) + os.Exit(1) + } segmentsByKey := make(map[string]interface{}) for _, segment := range segments { @@ -611,26 +633,207 @@ func getSegments(featurevisorProjectPath string) map[string]interface{} { return segmentsByKey } +func buildDatafileJSON(featurevisorProjectPath string, environment *string, schemaVersion string, inflate int, tag *string) interface{} { + args := []string{"build"} + if environment != nil { + args = append(args, fmt.Sprintf("--environment=%s", *environment)) + } + if schemaVersion != "" { + args = append(args, fmt.Sprintf("--schema-version=%s", schemaVersion)) + } + if inflate > 0 { + args = append(args, fmt.Sprintf("--inflate=%d", inflate)) + } + if tag != nil { + args = append(args, fmt.Sprintf("--tag=%s", *tag)) + } + args = append(args, "--json") + + datafileOutput := mustExecuteFeaturevisorCommand(featurevisorProjectPath, args...) + var datafile interface{} + if err := json.Unmarshal([]byte(datafileOutput), &datafile); err != nil { + fmt.Printf("failed to parse datafile json: %v\n", err) + os.Exit(1) + } + + return datafile +} + +func ensureDatafilesBuilt(featurevisorProjectPath string, environment *string, schemaVersion string, inflate int) { + args := []string{"build"} + if environment != nil { + args = append(args, fmt.Sprintf("--environment=%s", *environment)) + } + if schemaVersion != "" { + args = append(args, fmt.Sprintf("--schema-version=%s", schemaVersion)) + } + if inflate > 0 { + args = append(args, fmt.Sprintf("--inflate=%d", inflate)) + } + args = append(args, "--no-state-files") + + _, err := executeFeaturevisorCommand(featurevisorProjectPath, args...) + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } +} + +func getDatafilesDirectoryPath(featurevisorProjectPath string, config map[string]interface{}) string { + datafilesDirectoryPath := "datafiles" + if raw, ok := config["datafilesDirectoryPath"].(string); ok && raw != "" { + datafilesDirectoryPath = raw + } + + if filepath.IsAbs(datafilesDirectoryPath) { + return datafilesDirectoryPath + } + + return filepath.Join(featurevisorProjectPath, datafilesDirectoryPath) +} + +func getScopedDatafileFromDisk(featurevisorProjectPath string, config map[string]interface{}, environment *string, scopeName string) interface{} { + filename := fmt.Sprintf("featurevisor-scope-%s.json", scopeName) + datafilesDirectoryPath := getDatafilesDirectoryPath(featurevisorProjectPath, config) + + var fullPath string + if environment != nil { + fullPath = filepath.Join(datafilesDirectoryPath, *environment, filename) + } else { + fullPath = filepath.Join(datafilesDirectoryPath, filename) + } + + content, err := os.ReadFile(fullPath) + if err != nil { + fmt.Printf("failed to read scoped datafile: %s (%v)\n", fullPath, err) + os.Exit(1) + } + + var datafile interface{} + if err := json.Unmarshal(content, &datafile); err != nil { + fmt.Printf("failed to parse scoped datafile json: %s (%v)\n", fullPath, err) + os.Exit(1) + } + + return datafile +} + func buildDatafiles(featurevisorProjectPath string, environments []string, schemaVersion string, inflate int) map[string]interface{} { datafilesByEnvironment := make(map[string]interface{}) for _, environment := range environments { - fmt.Printf("Building datafile for environment: %s...\n", environment) - command := fmt.Sprintf("(cd %s && npx featurevisor build --environment=%s --json)", featurevisorProjectPath, environment) - if schemaVersion != "" { - command = fmt.Sprintf("(cd %s && npx featurevisor build --environment=%s --schemaVersion=%s --json)", featurevisorProjectPath, environment, schemaVersion) - } - if inflate > 0 { - command = fmt.Sprintf("(cd %s && npx featurevisor build --environment=%s --inflate=%d --json)", featurevisorProjectPath, environment, inflate) - if schemaVersion != "" { - command = fmt.Sprintf("(cd %s && npx featurevisor build --environment=%s --schemaVersion=%s --inflate=%d --json)", featurevisorProjectPath, environment, schemaVersion, inflate) + envCopy := environment + datafilesByEnvironment[environment] = buildDatafileJSON( + featurevisorProjectPath, + &envCopy, + schemaVersion, + inflate, + nil, + ) + } + return datafilesByEnvironment +} + +func datafileCacheKey(environment *string) string { + if environment == nil { + return noEnvironmentKey + } + + return *environment +} + +func scopedDatafileCacheKey(environment *string, scope string) string { + base := "scope" + if environment != nil { + base = *environment + "-scope" + } + + return fmt.Sprintf("%s-%s", base, scope) +} + +func taggedDatafileCacheKey(environment *string, tag string) string { + base := "tag" + if environment != nil { + base = *environment + "-tag" + } + + return fmt.Sprintf("%s-%s", base, tag) +} + +func buildDatafileCache( + featurevisorProjectPath string, + config map[string]interface{}, + schemaVersion string, + inflate int, + withScopes bool, + withTags bool, +) map[string]interface{} { + cache := make(map[string]interface{}) + + environments := []*string{nil} + if envList, ok := config["environments"].([]interface{}); ok { + environments = make([]*string, 0, len(envList)) + for _, raw := range envList { + if env, ok := raw.(string); ok { + envCopy := env + environments = append(environments, &envCopy) } } - datafileOutput := executeCommand(command) - var datafile interface{} - json.Unmarshal([]byte(datafileOutput), &datafile) - datafilesByEnvironment[environment] = datafile } - return datafilesByEnvironment + + for _, environment := range environments { + baseKey := datafileCacheKey(environment) + cache[baseKey] = buildDatafileJSON(featurevisorProjectPath, environment, schemaVersion, inflate, nil) + + if withTags { + if tags, ok := config["tags"].([]interface{}); ok { + for _, rawTag := range tags { + tag, ok := rawTag.(string) + if !ok { + continue + } + + tagCopy := tag + cache[taggedDatafileCacheKey(environment, tag)] = buildDatafileJSON( + featurevisorProjectPath, + environment, + schemaVersion, + inflate, + &tagCopy, + ) + } + } + } + + if withScopes { + scopesRaw, ok := config["scopes"].([]interface{}) + if !ok || len(scopesRaw) == 0 { + continue + } + + ensureDatafilesBuilt(featurevisorProjectPath, environment, schemaVersion, inflate) + + for _, rawScope := range scopesRaw { + scopeMap, ok := rawScope.(map[string]interface{}) + if !ok { + continue + } + + scopeName, ok := scopeMap["name"].(string) + if !ok || scopeName == "" { + continue + } + + cache[scopedDatafileCacheKey(environment, scopeName)] = getScopedDatafileFromDisk( + featurevisorProjectPath, + config, + environment, + scopeName, + ) + } + } + } + + return cache } func getLoggerLevel(opts CLIOptions) string { @@ -644,26 +847,102 @@ func getLoggerLevel(opts CLIOptions) string { } func getTests(featurevisorProjectPath string, opts CLIOptions) []map[string]interface{} { - testsSuffix := "" + args := []string{"list", "--tests", "--applyMatrix", "--json"} if opts.KeyPattern != "" { - testsSuffix = fmt.Sprintf(" --keyPattern=%s", opts.KeyPattern) + args = append(args, fmt.Sprintf("--keyPattern=%s", opts.KeyPattern)) } if opts.AssertionPattern != "" { - testsSuffix += fmt.Sprintf(" --assertionPattern=%s", opts.AssertionPattern) + args = append(args, fmt.Sprintf("--assertionPattern=%s", opts.AssertionPattern)) } - testsOutput := executeCommand(fmt.Sprintf("(cd %s && npx featurevisor list --tests --applyMatrix --json%s)", featurevisorProjectPath, testsSuffix)) + testsOutput := mustExecuteFeaturevisorCommand(featurevisorProjectPath, args...) var tests []map[string]interface{} - json.Unmarshal([]byte(testsOutput), &tests) + if err := json.Unmarshal([]byte(testsOutput), &tests); err != nil { + fmt.Printf("failed to parse tests json: %v\n", err) + os.Exit(1) + } return tests } +func buildInstanceForAssertion(datafile interface{}, level string, assertion map[string]interface{}) *featurevisor.Featurevisor { + var datafileContent featurevisor.DatafileContent + if datafileBytes, err := json.Marshal(datafile); err == nil { + if err := json.Unmarshal(datafileBytes, &datafileContent); err != nil { + fmt.Printf("failed to parse datafile for assertion: %v\n", err) + os.Exit(1) + } + } else { + fmt.Printf("failed to marshal datafile for assertion: %v\n", err) + os.Exit(1) + } + + levelStr := featurevisor.LogLevel(level) + return featurevisor.CreateInstance(featurevisor.Options{ + Datafile: datafileContent, + LogLevel: &levelStr, + Hooks: []*featurevisor.Hook{ + { + Name: "tester-hook", + BucketValue: func(options featurevisor.ConfigureBucketValueOptions) int { + if at, ok := assertion["at"].(float64); ok { + return int(at * 1000) + } + return options.BucketValue + }, + }, + }, + }) +} + +func toContextMap(value interface{}) map[string]interface{} { + if value == nil { + return map[string]interface{}{} + } + if context, ok := value.(map[string]interface{}); ok { + return context + } + return map[string]interface{}{} +} + +func getScopesByName(config map[string]interface{}) map[string]map[string]interface{} { + result := map[string]map[string]interface{}{} + scopesRaw, ok := config["scopes"].([]interface{}) + if !ok { + return result + } + + for _, rawScope := range scopesRaw { + scopeMap, ok := rawScope.(map[string]interface{}) + if !ok { + continue + } + + scopeName, ok := scopeMap["name"].(string) + if !ok || scopeName == "" { + continue + } + + scopeContext := toContextMap(scopeMap["context"]) + result[scopeName] = scopeContext + } + + return result +} + +func cloneAssertion(assertion map[string]interface{}) map[string]interface{} { + cloned := make(map[string]interface{}, len(assertion)) + for key, value := range assertion { + cloned[key] = value + } + return cloned +} + func runTest(opts CLIOptions) { featurevisorProjectPath := opts.ProjectDirectoryPath config := getConfig(featurevisorProjectPath) - environments := config["environments"].([]interface{}) segmentsByKey := getSegments(featurevisorProjectPath) + scopesByName := getScopesByName(config) // Use CLI schemaVersion option or fallback to config schemaVersion := opts.SchemaVersion @@ -673,7 +952,14 @@ func runTest(opts CLIOptions) { } } - datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, convertToStringSlice(environments), schemaVersion, opts.Inflate) + datafileCache := buildDatafileCache( + featurevisorProjectPath, + config, + schemaVersion, + opts.Inflate, + opts.WithScopes, + opts.WithTags, + ) fmt.Println() @@ -685,38 +971,6 @@ func runTest(opts CLIOptions) { return } - // Create SDK instances for each environment - sdkInstancesByEnvironment := make(map[string]*featurevisor.Featurevisor) - - for _, environment := range environments { - if envStr, ok := environment.(string); ok { - datafile := datafilesByEnvironment[envStr] - - // Convert datafile to proper format - var datafileContent featurevisor.DatafileContent - if datafileBytes, err := json.Marshal(datafile); err == nil { - json.Unmarshal(datafileBytes, &datafileContent) - } - - levelStr := featurevisor.LogLevel(level) - instance := featurevisor.CreateInstance(featurevisor.Options{ - Datafile: datafileContent, - LogLevel: &levelStr, - Hooks: []*featurevisor.Hook{ - { - Name: "tester-hook", - BucketValue: func(options featurevisor.ConfigureBucketValueOptions) int { - // This will be overridden per assertion if needed - return options.BucketValue - }, - }, - }, - }) - - sdkInstancesByEnvironment[envStr] = instance - } - } - passedTestsCount := 0 failedTestsCount := 0 passedAssertionsCount := 0 @@ -734,49 +988,60 @@ func runTest(opts CLIOptions) { var testResult AssertionResult if _, hasFeature := test["feature"]; hasFeature { - environment := assertionMap["environment"].(string) - instance := sdkInstancesByEnvironment[environment] + var environment *string + if rawEnvironment, exists := assertionMap["environment"]; exists { + if env, ok := rawEnvironment.(string); ok { + envCopy := env + environment = &envCopy + } + } + + selectedDatafileKey := datafileCacheKey(environment) + + if scopeValue, ok := assertionMap["scope"].(string); ok && scopeValue != "" { + if _, exists := datafileCache[scopedDatafileCacheKey(environment, scopeValue)]; exists { + selectedDatafileKey = scopedDatafileCacheKey(environment, scopeValue) + } + } + + if tagValue, ok := assertionMap["tag"].(string); ok && tagValue != "" { + if _, exists := datafileCache[taggedDatafileCacheKey(environment, tagValue)]; exists { + selectedDatafileKey = taggedDatafileCacheKey(environment, tagValue) + } + } + + datafile, ok := datafileCache[selectedDatafileKey] + if !ok { + fmt.Printf("missing datafile for key: %s\n", selectedDatafileKey) + os.Exit(1) + } + + effectiveAssertion := cloneAssertion(assertionMap) + if scopeValue, ok := assertionMap["scope"].(string); ok && scopeValue != "" && !opts.WithScopes { + if scopeContext, exists := scopesByName[scopeValue]; exists { + mergedContext := map[string]interface{}{} + for key, value := range scopeContext { + mergedContext[key] = value + } + for key, value := range toContextMap(assertionMap["context"]) { + mergedContext[key] = value + } + effectiveAssertion["context"] = mergedContext + } + } + + instance := buildInstanceForAssertion(datafile, level, effectiveAssertion) // Show datafile if requested (matching TypeScript implementation) if opts.ShowDatafile { - datafile := datafilesByEnvironment[environment] fmt.Println("") datafileJSON, _ := json.MarshalIndent(datafile, "", " ") fmt.Println(string(datafileJSON)) fmt.Println("") } - // If "at" parameter is provided, create a new instance with the specific hook - if _, hasAt := assertionMap["at"]; hasAt { - datafile := datafilesByEnvironment[environment] - var datafileContent featurevisor.DatafileContent - if datafileBytes, err := json.Marshal(datafile); err == nil { - json.Unmarshal(datafileBytes, &datafileContent) - } - - levelStr := featurevisor.LogLevel(level) - instance = featurevisor.CreateInstance(featurevisor.Options{ - Datafile: datafileContent, - LogLevel: &levelStr, - Hooks: []*featurevisor.Hook{ - { - Name: "tester-hook", - BucketValue: func(options featurevisor.ConfigureBucketValueOptions) int { - if at, ok := assertionMap["at"].(float64); ok { - // Match JavaScript implementation exactly: assertion.at * (MAX_BUCKETED_NUMBER / 100) - // MAX_BUCKETED_NUMBER is 100000, so this gives us 0-100000 range - // The JavaScript version uses: assertion.at * (MAX_BUCKETED_NUMBER / 100) - // where MAX_BUCKETED_NUMBER = 100000, so this becomes assertion.at * 1000 - return int(at * 1000) - } - return options.BucketValue - }, - }, - }, - }) - } - - testResult = RunTestFeature(assertionMap, test["feature"].(string), instance, level) + testResult = RunTestFeature(effectiveAssertion, test["feature"].(string), instance, level) + instance.Close() } else if _, hasSegment := test["segment"]; hasSegment { segmentKey := test["segment"].(string) segment := segmentsByKey[segmentKey] @@ -787,13 +1052,17 @@ func runTest(opts CLIOptions) { testDuration += testResult.Duration + description := "" + if desc, ok := assertionMap["description"].(string); ok { + description = desc + } if testResult.HasError { - results += fmt.Sprintf(" ✘ %s (%.2fms)\n", assertionMap["description"], testResult.Duration*1000) + results += fmt.Sprintf(" ✘ %s (%.2fms)\n", description, testResult.Duration*1000) results += testResult.Errors testHasError = true failedAssertionsCount++ } else { - results += fmt.Sprintf(" ✔ %s (%.2fms)\n", assertionMap["description"], testResult.Duration*1000) + results += fmt.Sprintf(" ✔ %s (%.2fms)\n", description, testResult.Duration*1000) passedAssertionsCount++ } } diff --git a/cmd/commands/test_types.go b/cmd/commands/test_types.go index f3e7b17..9cdca00 100644 --- a/cmd/commands/test_types.go +++ b/cmd/commands/test_types.go @@ -29,6 +29,8 @@ type FeatureAssertion struct { Matrix *AssertionMatrix `json:"matrix,omitempty"` Description *string `json:"description,omitempty"` Environment featurevisor.EnvironmentKey `json:"environment"` + Scope *string `json:"scope,omitempty"` + Tag *string `json:"tag,omitempty"` At *featurevisor.Weight `json:"at,omitempty"` Sticky *featurevisor.StickyFeatures `json:"sticky,omitempty"` Context *featurevisor.Context `json:"context,omitempty"` diff --git a/events.go b/events.go index 9f238fe..cd4c1f5 100644 --- a/events.go +++ b/events.go @@ -80,9 +80,6 @@ func getParamsForDatafileSetEvent(previousDatafileReader *DatafileReader, newDat "previousRevision": previousRevision, "revisionChanged": previousRevision != newRevision, "features": allAffectedFeatures, - "removedFeatures": removedFeatures, - "changedFeatures": changedFeatures, - "addedFeatures": addedFeatures, } } diff --git a/events_parity_test.go b/events_parity_test.go new file mode 100644 index 0000000..618323c --- /dev/null +++ b/events_parity_test.go @@ -0,0 +1,46 @@ +package featurevisor + +import "testing" + +func TestGetParamsForDatafileSetEventShape(t *testing.T) { + logger := NewLogger(CreateLoggerOptions{}) + + previousReader := NewDatafileReader(DatafileReaderOptions{ + Logger: logger, + Datafile: DatafileContent{ + SchemaVersion: "2", + Revision: "1", + Segments: map[SegmentKey]Segment{}, + Features: map[FeatureKey]Feature{ + "a": {Hash: parityStringPtr("h1"), BucketBy: "userId", Traffic: []Traffic{}}, + }, + }, + }) + newReader := NewDatafileReader(DatafileReaderOptions{ + Logger: logger, + Datafile: DatafileContent{ + SchemaVersion: "2", + Revision: "2", + Segments: map[SegmentKey]Segment{}, + Features: map[FeatureKey]Feature{ + "a": {Hash: parityStringPtr("h2"), BucketBy: "userId", Traffic: []Traffic{}}, + }, + }, + }) + + params := getParamsForDatafileSetEvent(previousReader, newReader) + + if _, exists := params["removedFeatures"]; exists { + t.Fatalf("did not expect removedFeatures field in event details") + } + if _, exists := params["changedFeatures"]; exists { + t.Fatalf("did not expect changedFeatures field in event details") + } + if _, exists := params["addedFeatures"]; exists { + t.Fatalf("did not expect addedFeatures field in event details") + } +} + +func parityStringPtr(value string) *string { + return &value +} diff --git a/helpers.go b/helpers.go index 2e9c50b..fad3ed2 100644 --- a/helpers.go +++ b/helpers.go @@ -42,15 +42,7 @@ func GetValueByType(value interface{}, fieldType string) interface{} { } return nil case "boolean": - switch v := value.(type) { - case bool: - return v - case string: - return v == "true" - case int: - return v != 0 - } - return false + return value == true case "array": if arr, ok := value.([]interface{}); ok { return arr diff --git a/helpers_parity_test.go b/helpers_parity_test.go new file mode 100644 index 0000000..a29e8d5 --- /dev/null +++ b/helpers_parity_test.go @@ -0,0 +1,15 @@ +package featurevisor + +import "testing" + +func TestGetValueByTypeBooleanParity(t *testing.T) { + if value := GetValueByType(true, "boolean"); value != true { + t.Fatalf("expected true bool to remain true") + } + if value := GetValueByType("true", "boolean"); value != false { + t.Fatalf("expected string \"true\" to not be coerced to true") + } + if value := GetValueByType(1, "boolean"); value != false { + t.Fatalf("expected numeric value to not be coerced to true") + } +} diff --git a/instance.go b/instance.go index bf3b9be..8cc7d6e 100644 --- a/instance.go +++ b/instance.go @@ -2,6 +2,7 @@ package featurevisor import ( "encoding/json" + "fmt" ) // OverrideOptions contains options for overriding evaluation @@ -79,28 +80,10 @@ func NewFeaturevisor(options Options) *Featurevisor { // If datafile is provided, set it if options.Datafile != nil { - var datafileContent DatafileContent - - if datafileStr, ok := options.Datafile.(string); ok { - // Parse JSON string using DatafileContent.FromJSON - if err := datafileContent.FromJSON(datafileStr); err == nil { - datafileReader = NewDatafileReader(DatafileReaderOptions{ - Datafile: datafileContent, - Logger: logger, - }) - } - } else if datafileMap, ok := options.Datafile.(map[string]interface{}); ok { - // Convert map to DatafileContent - if datafileBytes, err := json.Marshal(datafileMap); err == nil { - if err := datafileContent.FromJSON(string(datafileBytes)); err == nil { - datafileReader = NewDatafileReader(DatafileReaderOptions{ - Datafile: datafileContent, - Logger: logger, - }) - } - } - } else if datafileContent, ok := options.Datafile.(DatafileContent); ok { - // Direct DatafileContent + datafileContent, err := parseDatafileInput(options.Datafile) + if err != nil { + logger.Error("could not parse datafile", LogDetails{"error": err}) + } else { datafileReader = NewDatafileReader(DatafileReaderOptions{ Datafile: datafileContent, Logger: logger, @@ -128,15 +111,18 @@ func (i *Featurevisor) SetLogLevel(level LogLevel) { } // SetDatafile sets the datafile -func (i *Featurevisor) SetDatafile(datafile DatafileContent) { - datafileContent := datafile +func (i *Featurevisor) SetDatafile(datafile interface{}) { + datafileContent, err := parseDatafileInput(datafile) + if err != nil { + i.logger.Error("could not parse datafile", LogDetails{"error": err}) + return + } newDatafileReader := NewDatafileReader(DatafileReaderOptions{ Datafile: datafileContent, Logger: i.logger, }) - // Get details for datafile set event details := getParamsForDatafileSetEvent(i.datafileReader, newDatafileReader) i.datafileReader = newDatafileReader @@ -193,8 +179,8 @@ func (i *Featurevisor) AddHook(hook *Hook) { } // On adds an event listener -func (i *Featurevisor) On(eventName EventName, callback EventCallback) { - i.emitter.On(eventName, callback) +func (i *Featurevisor) On(eventName EventName, callback EventCallback) Unsubscribe { + return i.emitter.On(eventName, callback) } // Close closes the instance @@ -629,3 +615,35 @@ func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, func CreateInstance(options Options) *Featurevisor { return NewFeaturevisor(options) } + +func parseDatafileInput(datafile interface{}) (DatafileContent, error) { + var datafileContent DatafileContent + + switch value := datafile.(type) { + case string: + if err := datafileContent.FromJSON(value); err != nil { + return DatafileContent{}, fmt.Errorf("invalid datafile string: %w", err) + } + return datafileContent, nil + case map[string]interface{}: + bytes, err := json.Marshal(value) + if err != nil { + return DatafileContent{}, fmt.Errorf("failed to marshal datafile map: %w", err) + } + + if err := datafileContent.FromJSON(string(bytes)); err != nil { + return DatafileContent{}, fmt.Errorf("invalid datafile map: %w", err) + } + + return datafileContent, nil + case DatafileContent: + return value, nil + case *DatafileContent: + if value == nil { + return DatafileContent{}, fmt.Errorf("datafile pointer is nil") + } + return *value, nil + default: + return DatafileContent{}, fmt.Errorf("unsupported datafile input type: %T", datafile) + } +} diff --git a/instance_api_parity_test.go b/instance_api_parity_test.go new file mode 100644 index 0000000..08c15ca --- /dev/null +++ b/instance_api_parity_test.go @@ -0,0 +1,36 @@ +package featurevisor + +import "testing" + +func TestSetDatafileAcceptsJSONString(t *testing.T) { + instance := CreateInstance(Options{}) + instance.SetDatafile(`{ + "schemaVersion": "2", + "revision": "json-revision", + "segments": {}, + "features": {} + }`) + + if revision := instance.GetRevision(); revision != "json-revision" { + t.Fatalf("expected revision from json string datafile, got %s", revision) + } +} + +func TestInstanceOnReturnsUnsubscribe(t *testing.T) { + instance := CreateInstance(Options{}) + calls := 0 + + unsubscribe := instance.On(EventNameContextSet, func(details EventDetails) { + calls++ + }) + instance.SetContext(Context{"a": 1}) + if calls != 1 { + t.Fatalf("expected listener call count 1, got %d", calls) + } + + unsubscribe() + instance.SetContext(Context{"b": 2}) + if calls != 1 { + t.Fatalf("expected listener to be removed after unsubscribe, got %d", calls) + } +} diff --git a/sdk_types.go b/sdk_types.go index 96907e9..654449a 100644 --- a/sdk_types.go +++ b/sdk_types.go @@ -243,7 +243,7 @@ type RequiredWithVariation struct { type Required interface{} // Weight represents a weight value (0 to 100) -type Weight = int +type Weight = float64 // EnvironmentKey represents the key of an environment type EnvironmentKey = string @@ -371,6 +371,35 @@ const ( // VariableValue represents the value of a variable type VariableValue interface{} +// Value represents schema validation values +type Value interface{} + +// SchemaKey represents reusable schema key +type SchemaKey = string + +// Schema represents JSON schema-like validations used by variable schema. +type Schema struct { + Type *VariableType `json:"type,omitempty"` + Properties SchemaMap `json:"properties,omitempty"` + AdditionalProperties interface{} `json:"additionalProperties,omitempty"` // bool | Schema + Required []string `json:"required,omitempty"` + Items *Schema `json:"items,omitempty"` + OneOf []Schema `json:"oneOf,omitempty"` + Enum []Value `json:"enum,omitempty"` + Const VariableValue `json:"const,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Pattern *string `json:"pattern,omitempty"` + MinItems *int `json:"minItems,omitempty"` + MaxItems *int `json:"maxItems,omitempty"` + UniqueItems *bool `json:"uniqueItems,omitempty"` +} + +// SchemaMap represents schema object properties map. +type SchemaMap map[string]Schema + // VariableObjectValue represents an object with variable key-value pairs type VariableObjectValue map[string]VariableValue @@ -403,7 +432,23 @@ type VariableV1 struct { type VariableSchema struct { Deprecated *bool `json:"deprecated,omitempty"` Key *VariableKey `json:"key,omitempty"` - Type VariableType `json:"type"` + Type VariableType `json:"type,omitempty"` + Schema *SchemaKey `json:"schema,omitempty"` + Properties SchemaMap `json:"properties,omitempty"` + AdditionalProperties interface{} `json:"additionalProperties,omitempty"` // bool | Schema + Required []string `json:"required,omitempty"` + Items *Schema `json:"items,omitempty"` + OneOf []Schema `json:"oneOf,omitempty"` + Enum []Value `json:"enum,omitempty"` + Const VariableValue `json:"const,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Pattern *string `json:"pattern,omitempty"` + MinItems *int `json:"minItems,omitempty"` + MaxItems *int `json:"maxItems,omitempty"` + UniqueItems *bool `json:"uniqueItems,omitempty"` DefaultValue VariableValue `json:"defaultValue"` Description *string `json:"description,omitempty"` UseDefaultWhenDisabled *bool `json:"useDefaultWhenDisabled,omitempty"` From fd3defef5b7693ee268fb3beb62c184a56f11347 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 27 Feb 2026 23:42:31 +0100 Subject: [PATCH 4/5] generics --- README.md | 12 +++ child.go | 65 +++++++++--- generic_variable_methods_test.go | 176 +++++++++++++++++++++++++++++++ helpers.go | 128 ++++++++++++++++++++++ instance.go | 63 ++++++++--- 5 files changed, 412 insertions(+), 32 deletions(-) create mode 100644 generic_variable_methods_test.go diff --git a/README.md b/README.md index 50ca3b7..85a7848 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,18 @@ f.GetVariableObject(featureKey, variableKey, context) f.GetVariableJSON(featureKey, variableKey, context) ``` +For typed arrays/objects, use `Into` methods with pointer outputs: + +```go +var items []string +_ = f.GetVariableArrayInto(featureKey, variableKey, context, &items) + +var cfg MyConfig +_ = f.GetVariableObjectInto(featureKey, variableKey, context, &cfg) +``` + +`context` and `OverrideOptions` are optional and can be passed before the output pointer. + ## Getting all evaluations You can get evaluations of all features available in the SDK instance: diff --git a/child.go b/child.go index 4352923..110d335 100644 --- a/child.go +++ b/child.go @@ -1,5 +1,7 @@ package featurevisor +import "fmt" + // ChildOptions contains options for creating a child instance type ChildOptions struct { Parent *Featurevisor @@ -349,18 +351,7 @@ func (c *FeaturevisorChild) GetVariableArray(featureKey string, variableKey stri return nil } - typedValue := GetValueByType(value, "array") - if arrayValue, ok := typedValue.([]interface{}); ok { - result := make([]string, len(arrayValue)) - for i, item := range arrayValue { - if strItem, ok := item.(string); ok { - result[i] = strItem - } - } - return result - } - - return nil + return ToTypedArray[string](GetValueByType(value, "array")) } // GetVariableObject gets an object variable @@ -370,12 +361,12 @@ func (c *FeaturevisorChild) GetVariableObject(featureKey string, variableKey str return nil } - typedValue := GetValueByType(value, "object") - if objectValue, ok := typedValue.(map[string]interface{}); ok { - return objectValue + typedValue := ToTypedObject[map[string]interface{}](GetValueByType(value, "object")) + if typedValue == nil { + return nil } - return nil + return *typedValue } // GetVariableJSON gets a JSON variable @@ -388,6 +379,48 @@ func (c *FeaturevisorChild) GetVariableJSON(featureKey string, variableKey strin return value } +// GetVariableArrayInto decodes an array variable into the provided pointer output. +// Supported argument order (after featureKey, variableKey): out OR context, out OR context, options, out. +func (c *FeaturevisorChild) GetVariableArrayInto(featureKey string, variableKey string, args ...interface{}) error { + context, options, out, err := parseVariableIntoArgs(args...) + if err != nil { + return err + } + + value := c.GetVariable(featureKey, variableKey, context, options) + if value == nil { + return decodeInto(nil, out) + } + + arrayValue := GetValueByType(value, "array") + if arrayValue == nil { + return fmt.Errorf("variable %q is not an array", variableKey) + } + + return decodeInto(arrayValue, out) +} + +// GetVariableObjectInto decodes an object variable into the provided pointer output. +// Supported argument order (after featureKey, variableKey): out OR context, out OR context, options, out. +func (c *FeaturevisorChild) GetVariableObjectInto(featureKey string, variableKey string, args ...interface{}) error { + context, options, out, err := parseVariableIntoArgs(args...) + if err != nil { + return err + } + + value := c.GetVariable(featureKey, variableKey, context, options) + if value == nil { + return decodeInto(nil, out) + } + + objectValue := GetValueByType(value, "object") + if objectValue == nil { + return fmt.Errorf("variable %q is not an object", variableKey) + } + + return decodeInto(objectValue, out) +} + // GetAllEvaluations gets all evaluations for features func (c *FeaturevisorChild) GetAllEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { result := EvaluatedFeatures{} diff --git a/generic_variable_methods_test.go b/generic_variable_methods_test.go new file mode 100644 index 0000000..cb744e6 --- /dev/null +++ b/generic_variable_methods_test.go @@ -0,0 +1,176 @@ +package featurevisor + +import "testing" + +type typedConfig struct { + Theme string `json:"theme"` +} + +type nestedPalette struct { + Name string `json:"name"` + Active bool `json:"active"` +} + +type nestedLayout struct { + Columns int `json:"columns"` + Meta map[string]any `json:"meta"` +} + +type complexConfig struct { + Theme string `json:"theme"` + Layout nestedLayout `json:"layout"` + Palettes []nestedPalette `json:"palettes"` + Flags []string `json:"flags"` + Threshold float64 `json:"threshold"` +} + +type rolloutStep struct { + Name string `json:"name"` + Percentage float64 `json:"percentage"` +} + +func TestGenericVariableMethods(t *testing.T) { + jsonDatafile := `{ + "schemaVersion": "2", + "revision": "1.0", + "segments": {}, + "features": { + "typed": { + "key": "typed", + "bucketBy": "userId", + "variablesSchema": { + "items": { + "key": "items", + "type": "array", + "defaultValue": ["a", "b", "c"] + }, + "config": { + "key": "config", + "type": "object", + "defaultValue": {"theme": "dark"} + }, + "complexConfig": { + "key": "complexConfig", + "type": "object", + "defaultValue": { + "theme": "light", + "layout": { + "columns": 3, + "meta": { + "region": "eu", + "version": 2 + } + }, + "palettes": [ + {"name": "primary", "active": true}, + {"name": "secondary", "active": false} + ], + "flags": ["beta", "edge"], + "threshold": 0.85 + } + }, + "rolloutPlan": { + "key": "rolloutPlan", + "type": "array", + "defaultValue": [ + {"name": "phase-1", "percentage": 10}, + {"name": "phase-2", "percentage": 55.5}, + {"name": "phase-3", "percentage": 100} + ] + } + }, + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "enabled": true, + "allocation": [] + } + ] + } + } + }` + + var datafile DatafileContent + if err := datafile.FromJSON(jsonDatafile); err != nil { + t.Fatalf("failed to parse datafile: %v", err) + } + + sdk := CreateInstance(Options{Datafile: datafile}) + context := Context{"userId": "123"} + + var arr []string + if err := sdk.GetVariableArrayInto("typed", "items", context, &arr); err != nil { + t.Fatalf("expected array decode to succeed, got error: %v", err) + } + if len(arr) != 3 || arr[0] != "a" || arr[2] != "c" { + t.Fatalf("expected typed array values, got %#v", arr) + } + + var config typedConfig + if err := sdk.GetVariableObjectInto("typed", "config", context, &config); err != nil { + t.Fatalf("expected object decode to succeed, got error: %v", err) + } + if config.Theme != "dark" { + t.Fatalf("expected typed object with theme=dark, got %#v", config) + } + + var complex complexConfig + if err := sdk.GetVariableObjectInto("typed", "complexConfig", context, OverrideOptions{}, &complex); err != nil { + t.Fatalf("expected complex object decode to succeed, got error: %v", err) + } + if complex.Theme != "light" || complex.Layout.Columns != 3 || complex.Layout.Meta["region"] != "eu" { + t.Fatalf("unexpected complex config core fields: %#v", complex) + } + if len(complex.Palettes) != 2 || complex.Palettes[0].Name != "primary" || !complex.Palettes[0].Active { + t.Fatalf("unexpected complex config palettes: %#v", complex.Palettes) + } + if len(complex.Flags) != 2 || complex.Flags[1] != "edge" || complex.Threshold != 0.85 { + t.Fatalf("unexpected complex config flags/threshold: %#v", complex) + } + + var rollout []rolloutStep + if err := sdk.GetVariableArrayInto("typed", "rolloutPlan", context, OverrideOptions{}, &rollout); err != nil { + t.Fatalf("expected rollout decode to succeed, got error: %v", err) + } + if len(rollout) != 3 { + t.Fatalf("expected 3 rollout entries, got %#v", rollout) + } + if rollout[0].Name != "phase-1" || rollout[1].Percentage != 55.5 || rollout[2].Percentage != 100 { + t.Fatalf("unexpected rollout values: %#v", rollout) + } + + child := sdk.Spawn(Context{"country": "nl"}) + var childArr []string + if err := child.GetVariableArrayInto("typed", "items", &childArr); err != nil { + t.Fatalf("expected child array decode to succeed, got error: %v", err) + } + if len(childArr) != 3 || childArr[1] != "b" { + t.Fatalf("expected child typed array values, got %#v", childArr) + } + + var childConfig typedConfig + if err := child.GetVariableObjectInto("typed", "config", &childConfig); err != nil { + t.Fatalf("expected child object decode to succeed, got error: %v", err) + } + if childConfig.Theme != "dark" { + t.Fatalf("expected child typed object with theme=dark, got %#v", childConfig) + } + + var childComplex complexConfig + if err := child.GetVariableObjectInto("typed", "complexConfig", Context{}, &childComplex); err != nil { + t.Fatalf("expected child complex object decode to succeed, got error: %v", err) + } + if childComplex.Layout.Meta["region"] != "eu" || childComplex.Palettes[1].Name != "secondary" { + t.Fatalf("unexpected child complex config: %#v", childComplex) + } + + var childRollout []rolloutStep + if err := child.GetVariableArrayInto("typed", "rolloutPlan", Context{}, &childRollout); err != nil { + t.Fatalf("expected child rollout decode to succeed, got error: %v", err) + } + if len(childRollout) != 3 || childRollout[2].Name != "phase-3" { + t.Fatalf("unexpected child rollout values: %#v", childRollout) + } +} diff --git a/helpers.go b/helpers.go index fad3ed2..4f8cad0 100644 --- a/helpers.go +++ b/helpers.go @@ -1,6 +1,9 @@ package featurevisor import ( + "encoding/json" + "fmt" + "reflect" "strconv" ) @@ -60,3 +63,128 @@ func GetValueByType(value interface{}, fieldType string) interface{} { return value } } + +func convertToTypedValue[T any](value interface{}) (T, bool) { + var zero T + if typed, ok := value.(T); ok { + return typed, true + } + + bytes, err := json.Marshal(value) + if err != nil { + return zero, false + } + + var converted T + if err := json.Unmarshal(bytes, &converted); err != nil { + return zero, false + } + + return converted, true +} + +func ToTypedArray[T any](value interface{}) []T { + if value == nil { + return nil + } + + if typed, ok := value.([]T); ok { + return typed + } + + values, ok := value.([]interface{}) + if !ok { + return nil + } + + result := make([]T, len(values)) + for i, item := range values { + typedItem, ok := convertToTypedValue[T](item) + if !ok { + return nil + } + result[i] = typedItem + } + + return result +} + +func ToTypedObject[T any](value interface{}) *T { + if value == nil { + return nil + } + + typed, ok := convertToTypedValue[T](value) + if !ok { + return nil + } + + return &typed +} + +func setPointerTargetToZero(out interface{}) error { + if out == nil { + return fmt.Errorf("output argument is required") + } + + outValue := reflect.ValueOf(out) + if outValue.Kind() != reflect.Ptr || outValue.IsNil() { + return fmt.Errorf("output argument must be a non-nil pointer") + } + + target := outValue.Elem() + if !target.CanSet() { + return fmt.Errorf("output argument cannot be set") + } + + target.Set(reflect.Zero(target.Type())) + return nil +} + +func decodeInto(value interface{}, out interface{}) error { + if err := setPointerTargetToZero(out); err != nil { + return err + } + if value == nil { + return nil + } + + bytes, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to marshal variable value: %w", err) + } + + if err := json.Unmarshal(bytes, out); err != nil { + return fmt.Errorf("failed to decode variable value into output: %w", err) + } + + return nil +} + +func parseVariableIntoArgs(args ...interface{}) (Context, OverrideOptions, interface{}, error) { + context := Context{} + options := OverrideOptions{} + var out interface{} + + for _, arg := range args { + switch value := arg.(type) { + case Context: + context = value + case map[string]interface{}: + context = Context(value) + case OverrideOptions: + options = value + default: + if out != nil { + return Context{}, OverrideOptions{}, nil, fmt.Errorf("multiple output arguments provided") + } + out = arg + } + } + + if out == nil { + return Context{}, OverrideOptions{}, nil, fmt.Errorf("missing output pointer argument") + } + + return context, options, out, nil +} diff --git a/instance.go b/instance.go index 8cc7d6e..864c82a 100644 --- a/instance.go +++ b/instance.go @@ -523,18 +523,7 @@ func (i *Featurevisor) GetVariableArray(featureKey string, variableKey string, a return nil } - typedValue := GetValueByType(value, "array") - if arrayValue, ok := typedValue.([]interface{}); ok { - result := make([]string, len(arrayValue)) - for i, item := range arrayValue { - if strItem, ok := item.(string); ok { - result[i] = strItem - } - } - return result - } - - return nil + return ToTypedArray[string](GetValueByType(value, "array")) } // GetVariableObject gets an object variable @@ -544,12 +533,12 @@ func (i *Featurevisor) GetVariableObject(featureKey string, variableKey string, return nil } - typedValue := GetValueByType(value, "object") - if objectValue, ok := typedValue.(map[string]interface{}); ok { - return objectValue + typedValue := ToTypedObject[map[string]interface{}](GetValueByType(value, "object")) + if typedValue == nil { + return nil } - return nil + return *typedValue } // GetVariableJSON gets a JSON variable @@ -563,6 +552,48 @@ func (i *Featurevisor) GetVariableJSON(featureKey string, variableKey string, ar return value } +// GetVariableArrayInto decodes an array variable into the provided pointer output. +// Supported argument order (after featureKey, variableKey): out OR context, out OR context, options, out. +func (i *Featurevisor) GetVariableArrayInto(featureKey string, variableKey string, args ...interface{}) error { + context, options, out, err := parseVariableIntoArgs(args...) + if err != nil { + return err + } + + value := i.GetVariable(featureKey, variableKey, context, options) + if value == nil { + return decodeInto(nil, out) + } + + arrayValue := GetValueByType(value, "array") + if arrayValue == nil { + return fmt.Errorf("variable %q is not an array", variableKey) + } + + return decodeInto(arrayValue, out) +} + +// GetVariableObjectInto decodes an object variable into the provided pointer output. +// Supported argument order (after featureKey, variableKey): out OR context, out OR context, options, out. +func (i *Featurevisor) GetVariableObjectInto(featureKey string, variableKey string, args ...interface{}) error { + context, options, out, err := parseVariableIntoArgs(args...) + if err != nil { + return err + } + + value := i.GetVariable(featureKey, variableKey, context, options) + if value == nil { + return decodeInto(nil, out) + } + + objectValue := GetValueByType(value, "object") + if objectValue == nil { + return fmt.Errorf("variable %q is not an object", variableKey) + } + + return decodeInto(objectValue, out) +} + // GetAllEvaluations gets all evaluations for features func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, options OverrideOptions) EvaluatedFeatures { result := EvaluatedFeatures{} From a58aa49a8a227e008a5301d7a0fd66343573280d Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 27 Feb 2026 23:48:31 +0100 Subject: [PATCH 5/5] updates --- README.md | 47 ++++++++++++++++++++++++++++------------------- instance.go | 6 +++--- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 85a7848..2820e3a 100644 --- a/README.md +++ b/README.md @@ -317,16 +317,19 @@ import ( ) f := featurevisor.CreateInstance(featurevisor.Options{ - Sticky: &StickyFeatures{ - "myFeatureKey": featurevisor.StickyFeature{ + Sticky: &featurevisor.StickyFeatures{ + "myFeatureKey": { Enabled: true, // optional - Variation: &featurevisor.VariationValue{Value: "treatment"}, + Variation: func() *featurevisor.VariationValue { + v := featurevisor.VariationValue("treatment") + return &v + }(), Variables: map[string]interface{}{ "myVariableKey": "myVariableValue", }, }, - "anotherFeatureKey": featurevisor.StickyFeature{ + "anotherFeatureKey": { Enabled: false, }, }, @@ -341,14 +344,17 @@ You can also set sticky features after the SDK is initialized: ```go f.SetSticky(featurevisor.StickyFeatures{ - "myFeatureKey": featurevisor.StickyFeature{ + "myFeatureKey": { Enabled: true, - Variation: &featurevisor.VariationValue{Value: "treatment"}, + Variation: func() *featurevisor.VariationValue { + v := featurevisor.VariationValue("treatment") + return &v + }(), Variables: map[string]interface{}{ "myVariableKey": "myVariableValue", }, }, - "anotherFeatureKey": featurevisor.StickyFeature{ + "anotherFeatureKey": { Enabled: false, }, }, true) // replace existing sticky features (false by default) @@ -494,14 +500,14 @@ You can listen to these events that can occur at various stages in your applicat ### `datafile_set` ```go -unsubscribe := f.On(featurevisor.EventNameDatafileSet, func(event featurevisor.Event) { - revision := event.Revision // new revision - previousRevision := event.PreviousRevision - revisionChanged := event.RevisionChanged // true if revision has changed +unsubscribe := f.On(featurevisor.EventNameDatafileSet, func(details featurevisor.EventDetails) { + revision := details["revision"] // new revision + previousRevision := details["previousRevision"] + revisionChanged := details["revisionChanged"] // true if revision has changed // list of feature keys that have new updates, // and you should re-evaluate them - features := event.Features + features := details["features"] // handle here }) @@ -521,9 +527,9 @@ compared to the previous datafile content that existed in the SDK instance. ### `context_set` ```go -unsubscribe := f.On(featurevisor.EventNameContextSet, func(event featurevisor.Event) { - replaced := event.Replaced // true if context was replaced - context := event.Context // the new context +unsubscribe := f.On(featurevisor.EventNameContextSet, func(details featurevisor.EventDetails) { + replaced := details["replaced"] // true if context was replaced + context := details["context"] // the new context fmt.Println("Context set") }) @@ -532,9 +538,9 @@ unsubscribe := f.On(featurevisor.EventNameContextSet, func(event featurevisor.Ev ### `sticky_set` ```go -unsubscribe := f.On(featurevisor.EventNameStickySet, func(event featurevisor.Event) { - replaced := event.Replaced // true if sticky features got replaced - features := event.Features // list of all affected feature keys +unsubscribe := f.On(featurevisor.EventNameStickySet, func(details featurevisor.EventDetails) { + replaced := details["replaced"] // true if sticky features got replaced + features := details["features"] // list of all affected feature keys fmt.Println("Sticky features set") }) @@ -642,7 +648,8 @@ f := featurevisor.CreateInstance(featurevisor.Options{ Or after initialization: ```go -f.AddHook(myCustomHook) +removeHook := f.AddHook(myCustomHook) +removeHook() ``` ## Child instance @@ -680,7 +687,9 @@ Similar to parent SDK, child instances also support several additional methods: - `GetVariableInteger` - `GetVariableDouble` - `GetVariableArray` +- `GetVariableArrayInto` - `GetVariableObject` +- `GetVariableObjectInto` - `GetVariableJSON` - `GetAllEvaluations` - `On` diff --git a/instance.go b/instance.go index 864c82a..045bd96 100644 --- a/instance.go +++ b/instance.go @@ -49,7 +49,7 @@ func NewFeaturevisor(options Options) *Featurevisor { if options.Logger != nil { logger = options.Logger } else { - level := LogLevelWarn + level := LogLevelInfo if options.LogLevel != nil { level = *options.LogLevel } @@ -174,8 +174,8 @@ func (i *Featurevisor) GetFeature(featureKey string) *Feature { } // AddHook adds a hook -func (i *Featurevisor) AddHook(hook *Hook) { - i.hooksManager.Add(hook) +func (i *Featurevisor) AddHook(hook *Hook) func() { + return i.hooksManager.Add(hook) } // On adds an event listener