Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions featuremanagement/feature_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
24 changes: 20 additions & 4 deletions featuremanagement/feature_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,23 @@ 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.
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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
249 changes: 249 additions & 0 deletions featuremanagement/targeting_context_accessor_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading