Repository: Unleash/unleash-client-go
Pull request #140 (2023-09-26 13:11:51)

Feat/dependent feature toggles
Implements dependent feature toggles for the golang-sdk


Patch:
diff --git a/api/feature.go b/api/feature.go
index ccdc700..085d4bb 100644
--- a/api/feature.go
+++ b/api/feature.go
@@ -47,6 +47,19 @@ type Feature struct {
 
 	// Variants is a list of variants of the feature toggle.
 	Variants []VariantInternal `json:"variants"`
+
+	// Dependencies is a list of feature toggle dependency objects
+	Dependencies *[]Dependency `json:"dependencies"`
+}
+
+type Dependency struct {
+	// Feature is the name of the feature toggle we depend upon
+	Feature string `json:"feature"`
+	// Variants contains a string of variants that the dependency should resolve to
+	Variants *[]string `json:"variants"`
+	// Enabled is the property that determines whether the dependency should be on or off
+	// If the property is absent from the payload it's assumed to be default on
+	Enabled *bool `json:"enabled"`
 }
 
 func (fr FeatureResponse) FeatureMap() map[string]interface{} {
diff --git a/client.go b/client.go
index f725bd7..5b2bd2e 100644
--- a/client.go
+++ b/client.go
@@ -254,12 +254,7 @@ func (uc *Client) isEnabled(feature string, options ...FeatureOption) api.Strate
 		o(&opts)
 	}
 
-	var f *api.Feature
-	if opts.resolver != nil {
-		f = opts.resolver(feature)
-	} else {
-		f = uc.repository.getToggle(feature)
-	}
+	f := resolveToggle(uc, opts, feature)
 
 	ctx := uc.staticContext
 	if opts.ctx != nil {
@@ -267,18 +262,17 @@ func (uc *Client) isEnabled(feature string, options ...FeatureOption) api.Strate
 	}
 
 	if f == nil {
-		if opts.fallbackFunc != nil {
-			return api.StrategyResult{
-				Enabled: opts.fallbackFunc(feature, ctx),
-			}
-		} else if opts.fallback != nil {
+		return handleFallback(opts, feature, ctx)
+	}
+
+	if f.Dependencies != nil && len(*f.Dependencies) > 0 {
+		dependenciesSatisfied := uc.isParentDependencySatisfied(f, *ctx)
+
+		if !dependenciesSatisfied {
 			return api.StrategyResult{
-				Enabled: *opts.fallback,
+				Enabled: false,
 			}
 		}
-		return api.StrategyResult{
-			Enabled: false,
-		}
 	}
 
 	if !f.Enabled {
@@ -345,6 +339,44 @@ func (uc *Client) isEnabled(feature string, options ...FeatureOption) api.Strate
 	}
 }
 
+func (uc *Client) isParentDependencySatisfied(feature *api.Feature, context context.Context) bool {
+	warnOnce := &WarnOnce{}
+
+	dependenciesSatisfied := func(parent api.Dependency) bool {
+		parentToggle := uc.repository.getToggle(parent.Feature)
+
+		if parentToggle == nil {
+			warnOnce.Warn("the parent toggle was not found in the cache, the evaluation of this dependency will always be false")
+			return false
+		}
+
+		if parentToggle.Dependencies != nil && len(*parentToggle.Dependencies) > 0 {
+			return false
+		}
+
+		// According to the schema, if the enabled property is absent we assume it's true.
+		if parent.Enabled == nil {
+			if parent.Variants != nil && len(*parent.Variants) > 0 {
+				variantName := uc.getVariantWithoutMetrics(parent.Feature, WithVariantContext(context)).Name
+				return contains(*parent.Variants, variantName)
+			}
+			return uc.isEnabled(parent.Feature, WithContext(context)).Enabled
+		}
+
+		return !uc.isEnabled(parent.Feature, WithContext(context)).Enabled
+	}
+
+	allDependenciesSatisfied := every(*feature.Dependencies, func(parent interface{}) bool {
+		return dependenciesSatisfied(parent.(api.Dependency))
+	})
+
+	if !allDependenciesSatisfied {
+		return false
+	}
+
+	return true
+}
+
 // GetVariant queries a variant as the specified feature is enabled.
 //
 // It is safe to call this method from multiple goroutines concurrently.
@@ -483,3 +515,30 @@ func (uc *Client) WaitForReady() {
 func (uc *Client) ListFeatures() []api.Feature {
 	return uc.repository.list()
 }
+
+func resolveToggle(unleashClient *Client, opts featureOption, featureName string) *api.Feature {
+	var feature *api.Feature
+	if opts.resolver != nil {
+		feature = opts.resolver(featureName)
+	} else {
+		feature = unleashClient.repository.getToggle(featureName)
+	}
+
+	return feature
+}
+
+func handleFallback(opts featureOption, featureName string, ctx *context.Context) api.StrategyResult {
+	if opts.fallbackFunc != nil {
+		return api.StrategyResult{
+			Enabled: opts.fallbackFunc(featureName, ctx),
+		}
+	} else if opts.fallback != nil {
+		return api.StrategyResult{
+			Enabled: *opts.fallback,
+		}
+	}
+
+	return api.StrategyResult{
+		Enabled: false,
+	}
+}
diff --git a/doc.go b/doc.go
index 2175281..a505430 100644
--- a/doc.go
+++ b/doc.go
@@ -3,7 +3,7 @@ Package unleash is a client library for connecting to an Unleash feature toggle
 
 See https://github.com/Unleash/unleash for more information.
 
-Basics
+# Basics
 
 The API is very simple. The main functions of interest are Initialize and IsEnabled. Calling Initialize
 will create a default client and if a listener is supplied, it will start the sync loop. Internally the client
@@ -17,27 +17,27 @@ creates a set of channels that it passes to both of the above components and it
 asynchronously. It is important to ensure that these channels get regularly drained to avoid blocking those
 Go routines. There are two ways this can be done.
 
-Using the Listener Interfaces
+# Using the Listener Interfaces
 
 The first and perhaps simplest way to "drive" the synchronization loop in the client is to provide a type
 that implements one or more of the listener interfaces. There are 3 interfaces and you can choose which ones
 you should implement:
- - ErrorListener
- - RepositoryListener
- - MetricsListener
+  - ErrorListener
+  - RepositoryListener
+  - MetricsListener
+
 If you are only interesting in tracking errors and warnings and don't care about any of the other signals,
 then you only need to implement the ErrorListener and pass this instance to WithListener(). The DebugListener
 shows an example of implementing all of the listeners in a single type.
 
-Reading the channels directly
+# Reading the channels directly
 
 If you would prefer to have control over draining the channels yourself, then you must not call WithListener().
 Instead, you should read all of the channels continuously inside a select. The WithInstance example shows how
 to do this. Note that all channels must be drained, even if you are not interested in the result.
 
-Examples
+# Examples
 
 The following examples show how to use the client in different scenarios.
-
 */
 package unleash
diff --git a/utils.go b/utils.go
index 4b2d1e8..d79d3d5 100644
--- a/utils.go
+++ b/utils.go
@@ -5,6 +5,8 @@ import (
 	"math/rand"
 	"os"
 	"os/user"
+	"reflect"
+	"sync"
 	"time"
 )
 
@@ -34,3 +36,45 @@ func getFetchURLPath(projectName string) string {
 	}
 	return "./client/features"
 }
+
+func contains(arr []string, str string) bool {
+	for _, item := range arr {
+		if item == str {
+			return true
+		}
+	}
+	return false
+}
+
+// WarnOnce is a type for handling warnings that should only be displayed once.
+type WarnOnce struct {
+	once sync.Once
+}
+
+// Warn logs the warning message once.
+func (wo *WarnOnce) Warn(message string) {
+	wo.once.Do(func() {
+		fmt.Println("Warning:", message)
+	})
+}
+
+func every(slice interface{}, condition func(interface{}) bool) bool {
+	sliceValue := reflect.ValueOf(slice)
+
+	if sliceValue.Kind() != reflect.Slice {
+		fmt.Println("Input is not a slice returning false")
+		return false
+	}
+
+	if (sliceValue.Len() == 0) {
+		return false
+	}
+
+	for i := 0; i < sliceValue.Len(); i++ {
+		element := sliceValue.Index(i).Interface()
+		if !condition(element) {
+			return false
+		}
+	}
+	return true
+}


Test patch:
diff --git a/api/feature_test.go b/api/feature_test.go
index 9f6a864..97741c0 100644
--- a/api/feature_test.go
+++ b/api/feature_test.go
@@ -9,30 +9,29 @@ import (
 func TestFeatureSegmentsResponse(t *testing.T) {
 	assert := assert.New(t)
 	featureResponse := FeatureResponse{
-			Features: []Feature{},
-			Segments: []Segment{
-				{Id: 1, Constraints: []Constraint{
-					{
-						ContextName: "custom-id",
-						Operator:    OperatorIn,
-						Values:      []string{"custom-ctx"},
-					}}},
-					{Id: 2, Constraints: []Constraint{
-					{
-						ContextName: "age",
-						Operator:    OperatorNumGte,
-						Value:      "5",
-					}}},
-			}}
+		Features: []Feature{},
+		Segments: []Segment{
+			{Id: 1, Constraints: []Constraint{
+				{
+					ContextName: "custom-id",
+					Operator:    OperatorIn,
+					Values:      []string{"custom-ctx"},
+				}}},
+			{Id: 2, Constraints: []Constraint{
+				{
+					ContextName: "age",
+					Operator:    OperatorNumGte,
+					Value:       "5",
+				}}},
+		}}
 
-	segmentsMap := featureResponse.SegmentsMap();
+	segmentsMap := featureResponse.SegmentsMap()
 
-	segmentOne := segmentsMap[1];
-	segmentTwo := segmentsMap[2];
+	segmentOne := segmentsMap[1]
+	segmentTwo := segmentsMap[2]
 	segmentThree := segmentsMap[3]
-	
+
 	assert.Equal(segmentOne[0].Operator, OperatorIn)
 	assert.Equal(segmentTwo[0].Operator, OperatorNumGte)
 	assert.Nil(segmentThree)
 }
-
diff --git a/metrics_test.go b/metrics_test.go
index 0fea62a..0a86830 100644
--- a/metrics_test.go
+++ b/metrics_test.go
@@ -262,3 +262,80 @@ func TestMetrics_SendMetricsFail(t *testing.T) {
 	// Now OnSent should have been called as /client/metrics returned 200.
 	mockListener.AssertCalled(t, "OnSent", mock.AnythingOfType("MetricsData"))
 }
+
+func TestMetrics_ShouldNotCountMetricsForParentToggles(t *testing.T) {
+	assert := assert.New(t)
+	defer gock.OffAll()
+
+	gock.New(mockerServer).
+		Post("/client/register").
+		Reply(200)
+
+	gock.New(mockerServer).
+		Get("/client/features").
+		Reply(200).
+		JSON(api.FeatureResponse{
+			Features: []api.Feature{
+				{
+					Name:        "parent",
+					Enabled:     true,
+					Description: "parent toggle",
+					Strategies: []api.Strategy{
+						{
+							Id:          1,
+							Name:        "flexibleRollout",
+							Constraints: []api.Constraint{},
+							Parameters: map[string]interface{}{
+								"rollout":    100,
+								"stickiness": "default",
+							},
+						},
+					},
+				},
+				{
+					Name:        "child",
+					Enabled:     true,
+					Description: "parent toggle",
+					Strategies: []api.Strategy{
+						{
+							Id:          1,
+							Name:        "flexibleRollout",
+							Constraints: []api.Constraint{},
+							Parameters: map[string]interface{}{
+								"rollout":    100,
+								"stickiness": "default",
+							},
+						},
+					},
+					Dependencies: &[]api.Dependency{
+						{
+							Feature: "parent",
+						},
+					},
+				},
+			},
+		})
+
+	mockListener := &MockedListener{}
+	mockListener.On("OnReady").Return()
+	mockListener.On("OnError").Return()
+	mockListener.On("OnRegistered", mock.AnythingOfType("ClientData"))
+	mockListener.On("OnCount", "child", true).Return()
+
+	client, err := NewClient(
+		WithUrl(mockerServer),
+		WithAppName(mockAppName),
+		WithInstanceId(mockInstanceId),
+		WithListener(mockListener),
+	)
+
+	client.WaitForReady()
+	client.IsEnabled("child")
+
+	assert.EqualValues(client.metrics.bucket.Toggles["child"].Yes, 1)
+	assert.EqualValues(client.metrics.bucket.Toggles["parent"].Yes, 0)
+	client.Close()
+
+	assert.Nil(err, "client should not return an error")
+	assert.True(gock.IsDone(), "there should be no more mocks")
+}
diff --git a/repository_test.go b/repository_test.go
index 350825a..bc8ce2e 100644
--- a/repository_test.go
+++ b/repository_test.go
@@ -115,7 +115,7 @@ func TestRepository_ParseAPIResponse(t *testing.T) {
 			}
 		}`)
 
-	reader := bytes.NewReader(data);
+	reader := bytes.NewReader(data)
 	dec := json.NewDecoder(reader)
 
 	var response api.FeatureResponse
@@ -126,4 +126,4 @@ func TestRepository_ParseAPIResponse(t *testing.T) {
 
 	assert.Equal(2, len(response.Features))
 	assert.Equal(0, len(response.Segments))
-}
\ No newline at end of file
+}
diff --git a/utils_test.go b/utils_test.go
index 7574e84..204269b 100644
--- a/utils_test.go
+++ b/utils_test.go
@@ -15,3 +15,105 @@ func TestGetFetchURLPath(t *testing.T) {
 	res = getFetchURLPath("myProject")
 	assert.Equal("./client/features?project=myProject", res)
 }
+
+func TestEvery(t *testing.T) {
+	t.Run("All Even Integers", func(t *testing.T) {
+		numbers := []int{2, 4, 6, 8, 10}
+		allEven := every(numbers, func(item interface{}) bool {
+			num, ok := item.(int)
+			if !ok {
+				t.Errorf("Expected an integer, got %T", item)
+				return false
+			}
+			return num%2 == 0
+		})
+		if !allEven {
+			t.Errorf("Expected all numbers to be even, but got false")
+		}
+	})
+
+	t.Run("All Long Strings", func(t *testing.T) {
+		words := []string{"apple", "banana", "cherry"}
+		allLong := every(words, func(item interface{}) bool {
+			str, ok := item.(string)
+			if !ok {
+				t.Errorf("Expected a string, got %T", item)
+				return false
+			}
+			return len(str) > 3
+		})
+		if !allLong {
+			t.Errorf("Expected all words to be long, but got false")
+		}
+	})
+
+	t.Run("Empty Slice", func(t *testing.T) {
+		emptySlice := []int{}
+		allEmpty := every(emptySlice, func(item interface{}) bool {
+			// This condition should not be reached for an empty slice.
+			t.Errorf("Unexpected condition reached")
+			return false
+		})
+
+		if allEmpty {
+			t.Errorf("Expected an empty slice to return false, but got true")
+		}
+	})
+
+	t.Run("invalid inout", func(t *testing.T) {
+		invalidInput := "string"
+		result := every(invalidInput, func(item interface{}) bool {
+			// This condition should not be reached for an empty slice.
+			return true
+		})
+
+		if result == true {
+			t.Errorf("Expected result to be false")
+		}
+	})
+
+	t.Run("Result should be false if one doesn't match the predicate", func(t *testing.T) {
+		words := []string{"apple", "banana", "cherry", "he"}
+		allLong := every(words, func(item interface{}) bool {
+			str, ok := item.(string)
+			if !ok {
+				t.Errorf("Expected a string, got %T", item)
+				return false
+			}
+			return len(str) > 3
+		})
+		if allLong == true {
+			t.Errorf("Expected all words to be long, but got false")
+		}
+	})
+}
+
+func TestContains(t *testing.T) {
+	t.Run("Element is present in the slice", func(t *testing.T) {
+		arr := []string{"apple", "banana", "cherry", "date", "fig"}
+		str := "banana"
+		result := contains(arr, str)
+		if !result {
+			t.Errorf("Expected '%s' to be in the slice, but it was not found", str)
+		}
+	})
+
+	t.Run("Element is not present in the slice", func(t *testing.T) {
+		arr := []string{"apple", "banana", "cherry", "date", "fig"}
+		str := "grape"
+		result := contains(arr, str)
+		if result {
+			t.Errorf("Expected '%s' not to be in the slice, but it was found", str)
+		}
+	})
+
+	t.Run("Empty slice should return false", func(t *testing.T) {
+		arr := []string{}
+		str := "apple"
+		result := contains(arr, str)
+		if result {
+			t.Errorf("Expected an empty slice to return false, but it returned true")
+		}
+	})
+}
+