From 3d62c6bc8385d309ba14997662a2cca4f70cdc9a Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 2 Apr 2026 17:18:17 +0800 Subject: [PATCH 1/2] targeting context accessor --- featuremanagement/feature_filter.go | 23 ++ featuremanagement/feature_manager.go | 24 +- .../targeting_context_accessor_test.go | 249 ++++++++++++++++++ 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 featuremanagement/targeting_context_accessor_test.go diff --git a/featuremanagement/feature_filter.go b/featuremanagement/feature_filter.go index aeda41b..a02250f 100644 --- a/featuremanagement/feature_filter.go +++ b/featuremanagement/feature_filter.go @@ -24,6 +24,29 @@ type TargetingContext struct { Groups []string } +// TargetingContextAccessor provides access to the current user's targeting context. +// This is useful in web applications where user identity is already available +// in the application context (e.g., session, auth token) and doesn't need to be +// passed explicitly to every IsEnabled or GetVariant call. +// +// Example with Gin: +// +// type GinTargetingAccessor struct { +// getContext func() *gin.Context +// } +// +// func (a *GinTargetingAccessor) GetTargetingContext() (TargetingContext, error) { +// c := a.getContext() +// return TargetingContext{ +// UserID: c.GetString("userId"), +// Groups: c.GetStringSlice("groups"), +// }, nil +// } +type TargetingContextAccessor interface { + // GetTargetingContext retrieves the current user's targeting context. + GetTargetingContext() (TargetingContext, error) +} + // FeatureFilter defines the interface for feature flag filters. // Filters determine whether a feature should be enabled based on certain conditions. // diff --git a/featuremanagement/feature_manager.go b/featuremanagement/feature_manager.go index 783fe69..2295bc8 100644 --- a/featuremanagement/feature_manager.go +++ b/featuremanagement/feature_manager.go @@ -16,8 +16,9 @@ func init() { // FeatureManager is responsible for evaluating feature flags and their variants. // It is the main entry point for interacting with the feature management library. type FeatureManager struct { - featureProvider FeatureFlagProvider - featureFilters map[string]FeatureFilter + featureProvider FeatureFlagProvider + featureFilters map[string]FeatureFilter + targetingContextAccessor TargetingContextAccessor } // Options configures the behavior of the FeatureManager. @@ -25,6 +26,13 @@ type Options struct { // Filters is a list of custom feature filters that will be used during feature flag evaluation. // Each filter must implement the FeatureFilter interface. Filters []FeatureFilter + + // TargetingContextAccessor provides an ambient targeting context for feature evaluation. + // When set, the FeatureManager will use it to retrieve the current user's targeting context + // whenever IsEnabled or GetVariant is called without an explicit app context. + // This avoids the need to pass TargetingContext to every call in web applications + // where user identity is already available in the request scope. + TargetingContextAccessor TargetingContextAccessor } // EvaluationResult contains information about a feature flag evaluation @@ -76,8 +84,9 @@ func NewFeatureManager(provider FeatureFlagProvider, options *Options) (*Feature } return &FeatureManager{ - featureProvider: provider, - featureFilters: featureFilters, + featureProvider: provider, + featureFilters: featureFilters, + targetingContextAccessor: options.TargetingContextAccessor, }, nil } @@ -232,6 +241,13 @@ func (fm *FeatureManager) evaluateFeature(featureFlag FeatureFlag, appContext an Feature: &featureFlag, } + // If no app context provided, try to get targeting context from accessor + if appContext == nil && fm.targetingContextAccessor != nil { + if tc, err := fm.targetingContextAccessor.GetTargetingContext(); err == nil { + appContext = tc + } + } + // Validate feature flag format if err := validateFeatureFlag(featureFlag); err != nil { return result, fmt.Errorf("invalid feature flag: %w", err) diff --git a/featuremanagement/targeting_context_accessor_test.go b/featuremanagement/targeting_context_accessor_test.go new file mode 100644 index 0000000..89ba10c --- /dev/null +++ b/featuremanagement/targeting_context_accessor_test.go @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package featuremanagement + +import ( + "fmt" + "testing" + + "github.com/go-viper/mapstructure/v2" +) + +// mockTargetingContextAccessor implements TargetingContextAccessor for testing +type mockTargetingContextAccessor struct { + targetingContext TargetingContext + err error +} + +func (m *mockTargetingContextAccessor) GetTargetingContext() (TargetingContext, error) { + return m.targetingContext, m.err +} + +func TestTargetingContextAccessor_IsEnabled(t *testing.T) { + featureFlagData := map[string]any{ + "ID": "TargetedFeature", + "Enabled": true, + "Conditions": map[string]any{ + "ClientFilters": []any{ + map[string]any{ + "Name": "Microsoft.Targeting", + "Parameters": map[string]any{ + "Audience": map[string]any{ + "Users": []any{"Alice"}, + "Groups": []any{}, + "DefaultRolloutPercentage": 0, + }, + }, + }, + }, + }, + } + + var featureFlag FeatureFlag + err := mapstructure.Decode(featureFlagData, &featureFlag) + if err != nil { + t.Fatalf("Failed to decode feature flag: %v", err) + } + + provider := &mockFeatureFlagProvider{ + featureFlags: []FeatureFlag{featureFlag}, + } + + tests := []struct { + name string + accessor *mockTargetingContextAccessor + expectedResult bool + expectError bool + }{ + { + name: "Accessor provides targeted user - should be enabled", + accessor: &mockTargetingContextAccessor{ + targetingContext: TargetingContext{UserID: "Alice"}, + }, + expectedResult: true, + }, + { + name: "Accessor provides non-targeted user - should be disabled", + accessor: &mockTargetingContextAccessor{ + targetingContext: TargetingContext{UserID: "Bob"}, + }, + expectedResult: false, + }, + { + name: "Accessor returns error - targeting filter fails gracefully", + accessor: &mockTargetingContextAccessor{ + err: fmt.Errorf("no user context available"), + }, + expectedResult: false, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + manager, err := NewFeatureManager(provider, &Options{ + TargetingContextAccessor: tc.accessor, + }) + if err != nil { + t.Fatalf("Failed to create feature manager: %v", err) + } + + // Call IsEnabled without appContext — accessor should provide targeting context + result, err := manager.IsEnabled("TargetedFeature") + if tc.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + if result != tc.expectedResult { + t.Errorf("Expected %v, got %v", tc.expectedResult, result) + } + }) + } +} + +func TestTargetingContextAccessor_ExplicitContextOverridesAccessor(t *testing.T) { + featureFlagData := map[string]any{ + "ID": "TargetedFeature", + "Enabled": true, + "Conditions": map[string]any{ + "ClientFilters": []any{ + map[string]any{ + "Name": "Microsoft.Targeting", + "Parameters": map[string]any{ + "Audience": map[string]any{ + "Users": []any{"Alice"}, + "Groups": []any{}, + "DefaultRolloutPercentage": 0, + }, + }, + }, + }, + }, + } + + var featureFlag FeatureFlag + err := mapstructure.Decode(featureFlagData, &featureFlag) + if err != nil { + t.Fatalf("Failed to decode feature flag: %v", err) + } + + provider := &mockFeatureFlagProvider{ + featureFlags: []FeatureFlag{featureFlag}, + } + + // Accessor returns "Bob" (not targeted), but explicit context says "Alice" (targeted) + accessor := &mockTargetingContextAccessor{ + targetingContext: TargetingContext{UserID: "Bob"}, + } + + manager, err := NewFeatureManager(provider, &Options{ + TargetingContextAccessor: accessor, + }) + if err != nil { + t.Fatalf("Failed to create feature manager: %v", err) + } + + // Explicit context should override the accessor + result, err := manager.IsEnabledWithAppContext("TargetedFeature", TargetingContext{UserID: "Alice"}) + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + if !result { + t.Error("Expected feature to be enabled for Alice (explicit context), but it was disabled") + } +} + +func TestTargetingContextAccessor_GetVariant(t *testing.T) { + featureFlagData := map[string]any{ + "ID": "VariantFeature", + "Enabled": true, + "Allocation": map[string]any{ + "DefaultWhenEnabled": "Small", + "User": []any{ + map[string]any{ + "Variant": "Big", + "Users": []any{"Alice"}, + }, + }, + }, + "Variants": []any{ + map[string]any{"Name": "Big", "ConfigurationValue": "500px"}, + map[string]any{"Name": "Small", "ConfigurationValue": "300px"}, + }, + } + + var featureFlag FeatureFlag + err := mapstructure.Decode(featureFlagData, &featureFlag) + if err != nil { + t.Fatalf("Failed to decode feature flag: %v", err) + } + + provider := &mockFeatureFlagProvider{ + featureFlags: []FeatureFlag{featureFlag}, + } + + accessor := &mockTargetingContextAccessor{ + targetingContext: TargetingContext{UserID: "Alice"}, + } + + manager, err := NewFeatureManager(provider, &Options{ + TargetingContextAccessor: accessor, + }) + if err != nil { + t.Fatalf("Failed to create feature manager: %v", err) + } + + // Call GetVariant with nil appContext — accessor should provide targeting context + variant, err := manager.GetVariant("VariantFeature", nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if variant == nil { + t.Fatal("Expected a variant but got nil") + } + + if variant.Name != "Big" { + t.Errorf("Expected variant 'Big' for Alice, got '%s'", variant.Name) + } +} + +func TestTargetingContextAccessor_NilAccessor(t *testing.T) { + featureFlagData := map[string]any{ + "ID": "SimpleFeature", + "Enabled": true, + } + + var featureFlag FeatureFlag + err := mapstructure.Decode(featureFlagData, &featureFlag) + if err != nil { + t.Fatalf("Failed to decode feature flag: %v", err) + } + + provider := &mockFeatureFlagProvider{ + featureFlags: []FeatureFlag{featureFlag}, + } + + // No accessor — should work fine for features without targeting + manager, err := NewFeatureManager(provider, nil) + if err != nil { + t.Fatalf("Failed to create feature manager: %v", err) + } + + result, err := manager.IsEnabled("SimpleFeature") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + if !result { + t.Error("Expected feature to be enabled") + } +} From dc6654a73aa65aae84e6fae3963c94d1ab01cd5e Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 2 Apr 2026 17:19:30 +0800 Subject: [PATCH 2/2] fmt --- featuremanagement/targeting_context_accessor_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/featuremanagement/targeting_context_accessor_test.go b/featuremanagement/targeting_context_accessor_test.go index 89ba10c..35bb149 100644 --- a/featuremanagement/targeting_context_accessor_test.go +++ b/featuremanagement/targeting_context_accessor_test.go @@ -13,7 +13,7 @@ import ( // mockTargetingContextAccessor implements TargetingContextAccessor for testing type mockTargetingContextAccessor struct { targetingContext TargetingContext - err error + err error } func (m *mockTargetingContextAccessor) GetTargetingContext() (TargetingContext, error) {