Repository: Unleash/unleash-client-go
Pull request #77 (2020-12-06 13:37:18)

Implement variants
Hi guys,
This pull request is to implement variants which allow to set up multiple variants for feature toggles.
Feel free to leave comment for any improvements.
Thanks.


Patch:
diff --git a/api/feature.go b/api/feature.go
index 3a2c757..1bd1301 100644
--- a/api/feature.go
+++ b/api/feature.go
@@ -1,7 +1,13 @@
 package api
 
 import (
+	"fmt"
+	"math/rand"
+	"strconv"
 	"time"
+
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/spaolacci/murmur3"
 )
 
 type ParameterMap map[string]interface{}
@@ -13,25 +19,28 @@ type FeatureResponse struct {
 
 type Feature struct {
 	// Name is the name of the feature toggle.
-	Name        string       `json:"name"`
+	Name string `json:"name"`
 
 	// Description is a description of the feature toggle.
-	Description string       `json:"description"`
+	Description string `json:"description"`
 
 	// Enabled indicates whether the feature was enabled or not.
-	Enabled     bool         `json:"enabled"`
+	Enabled bool `json:"enabled"`
 
 	// Strategies is a list of names of the strategies supported by the client.
-	Strategies  []Strategy   `json:"strategies"`
+	Strategies []Strategy `json:"strategies"`
 
 	// CreatedAt is the creation time of the feature toggle.
-	CreatedAt   time.Time    `json:"createdAt"`
+	CreatedAt time.Time `json:"createdAt"`
 
 	// Strategy is the strategy of the feature toggle.
-	Strategy    string       `json:"strategy"`
+	Strategy string `json:"strategy"`
 
 	// Parameters is the parameters of the feature toggle.
-	Parameters  ParameterMap `json:"parameters"`
+	Parameters ParameterMap `json:"parameters"`
+
+	// Variants is a list of variants of the feature toggle.
+	Variants []VariantInternal `json:"variants"`
 }
 
 func (fr FeatureResponse) FeatureMap() map[string]interface{} {
@@ -41,3 +50,67 @@ func (fr FeatureResponse) FeatureMap() map[string]interface{} {
 	}
 	return features
 }
+
+// Get variant for a given feature which is considered as enabled
+func (f Feature) GetVariant(ctx *context.Context) *Variant {
+	if f.Enabled && len(f.Variants) > 0 {
+		v := f.getOverrideVariant(ctx)
+		var variant *Variant
+		if v == nil {
+			variant = f.getVariantFromWeights(ctx)
+		} else {
+			variant = &v.Variant
+		}
+		variant.Enabled = true
+		return variant
+	}
+	return DISABLED_VARIANT
+}
+
+func (f Feature) getVariantFromWeights(ctx *context.Context) *Variant {
+	totalWeight := 0
+	for _, variant := range f.Variants {
+		totalWeight += variant.Weight
+	}
+	if totalWeight == 0 {
+		return DISABLED_VARIANT
+	}
+
+	target := getNormalizedNumber(getSeed(ctx), f.Name, totalWeight)
+	counter := uint32(0)
+	for _, variant := range f.Variants {
+		counter += uint32(variant.Weight)
+
+		if counter >= target {
+			return &variant.Variant
+		}
+	}
+	return DISABLED_VARIANT
+}
+
+func (f Feature) getOverrideVariant(ctx *context.Context) *VariantInternal {
+	for _, variant := range f.Variants {
+		for _, override := range variant.Overrides {
+			if override.matchValue(ctx) {
+				variant.Overrides = nil
+				return &variant
+			}
+		}
+	}
+	return nil
+}
+
+func getSeed(ctx *context.Context) string {
+	if ctx.UserId != "" {
+		return ctx.UserId
+	} else if ctx.SessionId != "" {
+		return ctx.SessionId
+	} else if ctx.RemoteAddress != "" {
+		return ctx.RemoteAddress
+	}
+	return strconv.Itoa(rand.Intn(10000))
+}
+
+func getNormalizedNumber(identifier, groupId string, normalizer int) uint32 {
+	return (murmur3.Sum32([]byte(fmt.Sprintf("%s:%s", groupId, identifier))) % uint32(normalizer)) + 1
+}
diff --git a/api/variant.go b/api/variant.go
new file mode 100644
index 0000000..086200b
--- /dev/null
+++ b/api/variant.go
@@ -0,0 +1,79 @@
+package api
+
+import "github.com/Unleash/unleash-client-go/v3/context"
+
+var DISABLED_VARIANT = &Variant{
+	Name:    "disabled",
+	Enabled: false,
+}
+
+type Payload struct {
+	// Type is the type of the payload
+	Type string `json:"type"`
+	// Value is the value of the payload type
+	Value string `json:"value"`
+}
+
+type Override struct {
+	// ContextName is the value of attribute context name
+	ContextName string `json:"contextName"`
+	// Values is the value of attribute values
+	Values []string `json:"values"`
+}
+
+type Variant struct {
+	// Name is the value of the variant name.
+	Name string `json:"name"`
+	// Payload is the value of the variant payload
+	Payload Payload `json:"payload"`
+	// Enabled indicates whether the feature which is extend by this variant was enabled or not.
+	Enabled bool `json:"enabled"`
+}
+
+type VariantInternal struct {
+	Variant
+	// Weight is the traffic ratio for the request
+	Weight int `json:"weight"`
+	// WeightType can be fixed or variable
+	WeightType string `json:"weightType"`
+	// Override is used to get a variant accoording to the Unleash context field
+	Overrides []Override `json:"overrides"`
+}
+
+func (o Override) getIdentifier(ctx *context.Context) string {
+	var value string
+	switch o.ContextName {
+	case "userId":
+		value = ctx.UserId
+	case "sessionId":
+		value = ctx.SessionId
+	case "remoteAddress":
+		value = ctx.RemoteAddress
+	default:
+		if len(ctx.Properties) > 0 {
+			for k, v := range ctx.Properties {
+				if k == o.ContextName {
+					value = v
+				}
+			}
+		}
+	}
+	return value
+}
+
+func (o Override) matchValue(ctx *context.Context) bool {
+	if len(o.Values) == 0 {
+		return false
+	}
+	for _, value := range o.Values {
+		if value == o.getIdentifier(ctx) {
+			return true
+		}
+	}
+	return false
+}
+
+// Get default variant if no variant is found
+func GetDefaultVariant() *Variant {
+	return DISABLED_VARIANT
+}
diff --git a/client.go b/client.go
index 6a38df4..4c8a7fd 100644
--- a/client.go
+++ b/client.go
@@ -285,6 +285,46 @@ func (uc *Client) IsEnabled(feature string, options ...FeatureOption) (enabled b
 	return false
 }
 
+// GetVariant queries a variant as the specified feature is enabled.
+//
+// It is safe to call this method from multiple goroutines concurrently.
+func (uc *Client) GetVariant(feature string, options ...VariantOption) *api.Variant {
+	defaultVariant := api.GetDefaultVariant()
+	
+	f := uc.repository.getToggle(feature)
+
+	var opts variantOption
+	for _, o := range options {
+		o(&opts)
+	}
+
+	ctx := uc.staticContext
+
+	if f == nil {
+		if opts.variantFallbackFunc != nil {
+			return opts.variantFallbackFunc(feature, ctx)
+		} else if opts.variantFallback != nil {
+			return opts.variantFallback
+		}
+		return defaultVariant
+	}
+
+	if !f.Enabled {
+		return defaultVariant
+	}
+
+	if len(f.Variants) == 0 {
+		return defaultVariant
+	}
+
+	variant := f.GetVariant(ctx)
+
+	defer func() {
+		uc.metrics.countVariants(feature, variant.Name)
+	}()
+	return variant
+}
+
 // Close stops the client from syncing data from the server.
 func (uc *Client) Close() error {
 	uc.repository.Close()
diff --git a/config.go b/config.go
index 66f1926..50f51d7 100644
--- a/config.go
+++ b/config.go
@@ -5,6 +5,7 @@ import (
 	"net/url"
 	"time"
 
+	"github.com/Unleash/unleash-client-go/v3/api"
 	"github.com/Unleash/unleash-client-go/v3/context"
 	"github.com/Unleash/unleash-client-go/v3/strategy"
 )
@@ -165,6 +166,33 @@ func WithContext(ctx context.Context) FeatureOption {
 	}
 }
 
+// VariantFallbackFunc represents a function to be called if the variant is not found.
+type VariantFallbackFunc func(feature string, ctx *context.Context) *api.Variant
+
+type variantOption struct {
+	variantFallback     *api.Variant
+	variantFallbackFunc VariantFallbackFunc
+}
+
+// VariantOption provides options for querying if a variant is found or not.
+type VariantOption func(*variantOption)
+
+// WithVariantFallback specifies what the value should be if the variant is not found on the
+// unleash service.
+func WithVariantFallback(variantFallback *api.Variant) VariantOption {
+	return func(opts *variantOption) {
+		opts.variantFallback = variantFallback
+	}
+}
+
+// WithVariantFallbackFunc specifies a fallback function to evaluate a variant
+// is not found on the service.
+func WithVariantFallbackFunc(variantFallbackFunc VariantFallbackFunc) VariantOption {
+	return func(opts *variantOption) {
+		opts.variantFallbackFunc = variantFallbackFunc
+	}
+}
+
 type repositoryOptions struct {
 	appName         string
 	instanceId      string
diff --git a/internal/api/metrics.go b/internal/api/metrics.go
index 9f8a844..41cfcd2 100644
--- a/internal/api/metrics.go
+++ b/internal/api/metrics.go
@@ -5,6 +5,7 @@ import "time"
 type ToggleCount struct {
 	Yes int32 `json:"yes"`
 	No  int32 `json:"no"`
+	Variants map[string]int32 `json:"variants"`
 }
 
 type Bucket struct {
diff --git a/metrics.go b/metrics.go
index e9e36ab..dc22948 100644
--- a/metrics.go
+++ b/metrics.go
@@ -228,6 +228,24 @@ func (m *metrics) count(name string, enabled bool) {
 	m.metricsChannels.count <- metric{Name: name, Enabled: enabled}
 }
 
+func (m *metrics) countVariants(name string, variantName string) {
+	if m.options.disableMetrics {
+		return
+	}
+	
+	t, _ := m.bucket.Toggles[name]
+	if len(t.Variants) == 0 {
+		t.Variants = make(map[string]int32)
+	} 
+
+	if _ , ok := t.Variants[variantName]; !ok {
+		t.Variants[variantName] = 1
+	} else {
+		t.Variants[variantName] += 1
+	}
+	m.bucket.Toggles[name] = t
+}
+
 func (m *metrics) resetBucket() api.Bucket {
 	prev := m.bucket
 	m.bucket = api.Bucket{


Test patch:
diff --git a/api/variant_test.go b/api/variant_test.go
new file mode 100644
index 0000000..078dbb4
--- /dev/null
+++ b/api/variant_test.go
@@ -0,0 +1,246 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/stretchr/testify/suite"
+)
+
+type VariantTestSuite struct {
+	suite.Suite
+	VariantWithOverride    []VariantInternal
+	VariantWithoutOverride []VariantInternal
+}
+
+func (suite *VariantTestSuite) SetupTest() {
+	suite.VariantWithOverride = []VariantInternal{
+		VariantInternal{
+			Variant: Variant{
+				Name: "VarA",
+				Payload: Payload{
+					Type:  "string",
+					Value: "Test 1",
+				},
+			},
+			Weight: 33,
+			Overrides: []Override{
+				Override{
+					ContextName: "userId",
+					Values: []string{
+						"1",
+					},
+				},
+				Override{
+					ContextName: "sessionId",
+					Values: []string{
+						"ABCDE",
+					},
+				},
+			},
+		},
+		VariantInternal{
+			Variant: Variant{
+				Name: "VarB",
+				Payload: Payload{
+					Type:  "string",
+					Value: "Test 2",
+				},
+			},
+			Weight: 33,
+			Overrides: []Override{
+				Override{
+					ContextName: "remoteAddress",
+					Values: []string{
+						"127.0.0.1",
+					},
+				},
+			},
+		},
+		VariantInternal{
+			Variant: Variant{
+				Name: "VarC",
+				Payload: Payload{
+					Type:  "string",
+					Value: "Test 3",
+				},
+			},
+			Weight: 34,
+			Overrides: []Override{
+				Override{
+					ContextName: "env",
+					Values: []string{
+						"dev",
+					},
+				},
+			},
+		},
+	}
+
+	suite.VariantWithoutOverride = []VariantInternal{
+		{
+			Variant: Variant{
+				Name: "VarD",
+			},
+			Weight: 33,
+		},
+		{
+			Variant: Variant{
+				Name: "VarE",
+			},
+			Weight: 33,
+		},
+		{
+			Variant: Variant{
+				Name: "VarF",
+			},
+			Weight: 34,
+		},
+	}
+}
+
+func (suite *VariantTestSuite) TestGetVariantWhenFeatureHasNoVariant() {
+	mockFeature := Feature{
+		Name:    "test.variants",
+		Enabled: true,
+	}
+	mockContext := &context.Context{}
+	suite.Equal(DISABLED_VARIANT, mockFeature.GetVariant(mockContext), "Should return default variant")
+}
+
+func (suite *VariantTestSuite) TestGetVariantWhenFeatureIsNotEnabled() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  false,
+		Variants: suite.VariantWithOverride,
+	}
+	mockContext := &context.Context{
+		UserId:        "1",
+		SessionId:     "ABCDE",
+		RemoteAddress: "127.0.0.1",
+	}
+	suite.Equal(DISABLED_VARIANT, mockFeature.GetVariant(mockContext), "Should return default variant")
+}
+
+func (suite *VariantTestSuite) TestGetVariant_OverrideOnUserId() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  true,
+		Variants: suite.VariantWithOverride,
+	}
+	mockContext := &context.Context{
+		UserId:        "1",
+		SessionId:     "ABCDE",
+		RemoteAddress: "127.0.0.1",
+	}
+	expectedPayload := Payload{
+		Type:  "string",
+		Value: "Test 1",
+	}
+	suite.Equal("VarA", mockFeature.GetVariant(mockContext).Name, "Should return VarA")
+	suite.Equal(true, mockFeature.GetVariant(mockContext).Enabled, "Should be equal")
+	suite.Equal(expectedPayload, mockFeature.GetVariant(mockContext).Payload, "Should be equal")
+}
+
+func (suite *VariantTestSuite) TestGetVariant_OverrideOnRemoteAddress() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  true,
+		Variants: suite.VariantWithOverride,
+	}
+	mockContext := &context.Context{
+		SessionId:     "FGHIJ",
+		RemoteAddress: "127.0.0.1",
+	}
+	expectedPayload := Payload{
+		Type:  "string",
+		Value: "Test 2",
+	}
+	suite.Equal("VarB", mockFeature.GetVariant(mockContext).Name, "Should return VarB")
+	suite.Equal(true, mockFeature.GetVariant(mockContext).Enabled, "Should be equal")
+	suite.Equal(expectedPayload, mockFeature.GetVariant(mockContext).Payload, "Should be equal")
+}
+
+func (suite *VariantTestSuite) TestGetVariant_OverrideOnSessionId() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  true,
+		Variants: suite.VariantWithOverride,
+	}
+	mockContext := &context.Context{
+		UserId:        "123",
+		SessionId:     "ABCDE",
+		RemoteAddress: "127.0.0.1",
+	}
+	expectedPayload := Payload{
+		Type:  "string",
+		Value: "Test 1",
+	}
+	suite.Equal("VarA", mockFeature.GetVariant(mockContext).Name, "Should return VarA")
+	suite.Equal(true, mockFeature.GetVariant(mockContext).Enabled, "Should be equal")
+	suite.Equal(expectedPayload, mockFeature.GetVariant(mockContext).Payload, "Should be equal")
+}
+
+func (suite *VariantTestSuite) TestGetVariant_OverrideOnCustomProperties() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  true,
+		Variants: suite.VariantWithOverride,
+	}
+	mockContext := &context.Context{
+		Properties: map[string]string{
+			"env": "dev",
+		},
+	}
+	expectedPayload := Payload{
+		Type:  "string",
+		Value: "Test 3",
+	}
+	suite.Equal("VarC", mockFeature.GetVariant(mockContext).Name, "Should return VarC")
+	suite.Equal(true, mockFeature.GetVariant(mockContext).Enabled, "Should be equal")
+	suite.Equal(expectedPayload, mockFeature.GetVariant(mockContext).Payload, "Should be equal")
+}
+
+func (suite *VariantTestSuite) TestGetVariant_ShouldReturnVarD() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  true,
+		Variants: suite.VariantWithoutOverride,
+	}
+	mockContext := &context.Context{
+		UserId: "123",
+	}
+	suite.Equal("VarD", mockFeature.GetVariant(mockContext).Name, "Should return VarD")
+	suite.Equal(true, mockFeature.GetVariant(mockContext).Enabled, "Should be equal")
+}
+
+func (suite *VariantTestSuite) TestGetVariant_ShouldReturnVarE() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  true,
+		Variants: suite.VariantWithoutOverride,
+	}
+	mockContext := &context.Context{
+		UserId: "163",
+	}
+	suite.Equal("VarE", mockFeature.GetVariant(mockContext).Name, "Should return VarE")
+	suite.Equal(true, mockFeature.GetVariant(mockContext).Enabled, "Should be equal")
+}
+
+func (suite *VariantTestSuite) TestGetVariant_ShouldReturnVarF() {
+	mockFeature := Feature{
+		Name:     "test.variants",
+		Enabled:  true,
+		Variants: suite.VariantWithoutOverride,
+	}
+	mockContext := &context.Context{
+		UserId: "40",
+	}
+	suite.Equal("VarF", mockFeature.GetVariant(mockContext).Name, "Should return VarF")
+	suite.Equal(true, mockFeature.GetVariant(mockContext).Enabled, "Should be equal")
+}
+
+func TestVariantSuite(t *testing.T) {
+	ts := VariantTestSuite{}
+	suite.Run(t, &ts)
+}
diff --git a/spec_test.go b/spec_test.go
index 4d121c3..5a58c8e 100644
--- a/spec_test.go
+++ b/spec_test.go
@@ -32,6 +32,22 @@ type TestCase struct {
 	ExpectedResult bool            `json:"expectedResult"`
 }
 
+type VariantTestCase struct {
+	Description    string          `json:"description"`
+	Context        context.Context `json:"context"`
+	ToggleName     string          `json:"toggleName"`
+	ExpectedResult *api.Variant    `json:"expectedResult"`
+}
+
+type Runner interface {
+	GetDescription() string
+	RunWithClient(*Client) func(*testing.T)
+}
+
+func (tc TestCase) GetDescription() string {
+	return tc.Description
+}
+
 func (tc TestCase) RunWithClient(client *Client) func(*testing.T) {
 	return func(t *testing.T) {
 		client.WaitForReady()
@@ -49,10 +65,34 @@ func (tc TestCase) RunWithClient(client *Client) func(*testing.T) {
 	}
 }
 
+func (vtc VariantTestCase) GetDescription() string {
+	return vtc.Description
+}
+
+func (vtc VariantTestCase) RunWithClient(client *Client) func(*testing.T) {
+	client.staticContext = &vtc.Context
+	return func(t *testing.T) {
+		client.WaitForReady()
+		var wg sync.WaitGroup
+		wg.Add(1)
+		go func() {
+			// Call IsEnabled concurrently with itself to catch
+			// potential data races with go test -race.
+			client.IsEnabled(vtc.ToggleName, WithContext(vtc.Context))
+			wg.Done()
+		}()
+		result := client.IsEnabled(vtc.ToggleName, WithContext(vtc.Context))
+		wg.Wait()
+		assert.Equal(t, vtc.ExpectedResult.Enabled, result)
+		assert.Equal(t, vtc.ExpectedResult, client.GetVariant(vtc.ToggleName))
+	}
+}
+
 type TestDefinition struct {
-	Name  string     `json:"name"`
-	State TestState  `json:"state"`
-	Tests []TestCase `json:"tests"`
+	Name         string            `json:"name"`
+	State        TestState         `json:"state"`
+	Tests        []TestCase        `json:"tests"`
+	VariantTests []VariantTestCase `json:"variantTests"`
 }
 
 func (td TestDefinition) Mock(listener interface{}) (*Client, error) {
@@ -82,7 +122,7 @@ func (td TestDefinition) Unmock() {
 }
 
 func (td TestDefinition) Run(t *testing.T) {
-	for _, test := range td.Tests {
+	runTest := func(test Runner) {
 		listener := &MockedListener{}
 		listener.On("OnReady").Return()
 		listener.On("OnRegistered", mock.AnythingOfType("ClientData")).Return()
@@ -90,7 +130,7 @@ func (td TestDefinition) Run(t *testing.T) {
 
 		client, err := td.Mock(listener)
 		assert.NoError(t, err)
-		t.Run(test.Description, test.RunWithClient(client))
+		t.Run(test.GetDescription(), test.RunWithClient(client))
 		client.Close()
 
 		listener.AssertCalled(t, "OnReady")
@@ -98,6 +138,14 @@ func (td TestDefinition) Run(t *testing.T) {
 
 		td.Unmock()
 	}
+
+	for _, test := range td.Tests {
+		runTest(test)
+	}
+
+	for _, test := range td.VariantTests {
+		runTest(test)
+	}
 }
 
 type ClientSpecificationSuite struct {