Skip to content

Commit

Permalink
#118 Add IsFeatureEnabled function to client (#119)
Browse files Browse the repository at this point in the history
* Added alternative function IsFeatureEnabled for when the Feature has already been retrieved.

* Handle case when retrieved or provided feature is nil.

* Added better message to notify user of hidden logic on nil case.

* Added tests and check to make sure nil dereference dosen't happen.

* Revert "Added tests and check to make sure nil dereference dosen't happen."

This reverts commit 0af2da1.

* Revert "Added better message to notify user of hidden logic on nil case."

This reverts commit d153db2.

* Revert "Handle case when retrieved or provided feature is nil."

This reverts commit 52bc8b0.

* Revert "Added alternative function IsFeatureEnabled for when the Feature has already been retrieved."

This reverts commit 436b302.

* Using suggestion from @jrbarron swapped to using variadic arguments.

* Re-add import that was removed.

* Resolve unit tests issues

* Updated readme to add feature resolver documentation.
  • Loading branch information
courupteddata authored Aug 2, 2022
1 parent 056d526 commit 459ff5b
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 7 deletions.
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,47 @@ unleash.IsEnabled("someToggle", unleash.WithContext(ctx))

This client uses go routines to report several events and doesn't drain the channel by default. So you need to either register a listener using `WithListener` or drain the channel "manually" (demonstrated in [this example](https://github.com/Unleash/unleash-client-go/blob/master/example_with_instance_test.go)).

### Feature Resolver

`FeatureResolver` is a `FeatureOption` used in `IsEnabled` via the `WithResolver`.

The `FeatureResolver` can be used to provide a feature instance in a different way than the client would normally retrieve it. This alternative resolver can be useful if you already have the feature instance and don't want to incur the cost to retrieve it from the repository.

An example of its usage is below:
```go
ctx := context.Context{
UserId: "123",
SessionId: "some-session-id",
RemoteAddress: "127.0.0.1",
}

// the FeatureResolver function that will be passed into WithResolver
resolver := func(featureName string) *api.Feature {
if featureName == "someToggle" {
// Feature being created in place for sake of example, but it is preferable an existing feature instance is used
return &api.Feature{
Name: "someToggle",
Description: "Example of someToggle",
Enabled: true,
Strategies: []api.Strategy{
{
Id: 1,
Name: "default",
},
},
CreatedAt: time.Time{},
Strategy: "default-strategy",
}
} else {
// it shouldn't reach this block because the name will match above "someToggle" for this example
return nil
}
}

// This would return true because the matched strategy is default and the feature is Enabled
unleash.IsEnabled("someToggle", unleash.WithContext(ctx), unleash.WithResolver(resolver))
```

## Development

## Steps to release
Expand Down
28 changes: 22 additions & 6 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
const (
deprecatedSuffix = "/features"
clientName = "unleash-client-go"
clientVersion = "3.6.0"
clientVersion = "3.7.0"
)

var defaultStrategies = []strategy.Strategy{
Expand Down Expand Up @@ -249,13 +249,18 @@ func (uc *Client) IsEnabled(feature string, options ...FeatureOption) (enabled b
// isEnabled abstracts away the details of checking if a toggle is turned on or off
// without metrics
func (uc *Client) isEnabled(feature string, options ...FeatureOption) (enabled bool) {
f := uc.repository.getToggle(feature)

var opts featureOption
for _, o := range options {
o(&opts)
}

var f *api.Feature
if opts.resolver != nil {
f = opts.resolver(feature)
} else {
f = uc.repository.getToggle(feature)
}

ctx := uc.staticContext
if opts.ctx != nil {
ctx = ctx.Override(*opts.ctx)
Expand Down Expand Up @@ -321,11 +326,22 @@ func (uc *Client) GetVariant(feature string, options ...VariantOption) *api.Vari
ctx = ctx.Override(*opts.ctx)
}

if !uc.isEnabled(feature, WithContext(*ctx)) {
return defaultVariant
if opts.resolver != nil {
if !uc.isEnabled(feature, WithContext(*ctx), WithResolver(opts.resolver)) {
return defaultVariant
}
} else {
if !uc.isEnabled(feature, WithContext(*ctx)) {
return defaultVariant
}
}

f := uc.repository.getToggle(feature)
var f *api.Feature
if opts.resolver != nil {
f = opts.resolver(feature)
} else {
f = uc.repository.getToggle(feature)
}

if f == nil {
if opts.variantFallbackFunc != nil {
Expand Down
142 changes: 142 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,69 @@ func TestClient_WithFallbackFunc(t *testing.T) {
assert.True(gock.IsDone(), "there should be no more mocks")
}

func TestClient_WithResolver(t *testing.T) {
assert := assert.New(t)
defer gock.OffAll()

gock.New(mockerServer).
Post("/client/register").
MatchHeader("UNLEASH-APPNAME", mockAppName).
MatchHeader("UNLEASH-INSTANCEID", mockInstanceId).
Reply(200)

gock.New(mockerServer).
Get("/client/features").
Reply(200).
JSON(api.FeatureResponse{})

const feature = "some_special_value"

mockListener := &MockedListener{}
mockListener.On("OnReady").Return()
mockListener.On("OnRegistered", mock.AnythingOfType("ClientData"))
mockListener.On("OnCount", feature, true).Return()
mockListener.On("OnError").Return()

client, err := NewClient(
WithUrl(mockerServer),
WithAppName(mockAppName),
WithInstanceId(mockInstanceId),
WithListener(mockListener),
)

assert.NoError(err)

client.WaitForReady()

resolver := func(featureName string) *api.Feature {
if featureName == feature {
return &api.Feature{
Name: "some_special_value-resolved",
Description: "",
Enabled: true,
Strategies: []api.Strategy{
{
Id: 1,
Name: "default",
},
},
CreatedAt: time.Time{},
Strategy: "default-strategy",
Parameters: nil,
Variants: nil,
}
} else {
t.Fatalf("the feature name passed %s was not the expected one %s", featureName, feature)
return nil
}
}

isEnabled := client.IsEnabled(feature, WithResolver(resolver))
assert.True(isEnabled)

assert.True(gock.IsDone(), "there should be no more mocks")
}

func TestClient_ListFeatures(t *testing.T) {
assert := assert.New(t)
defer gock.OffAll()
Expand Down Expand Up @@ -309,6 +372,20 @@ func TestClientWithVariantContext(t *testing.T) {
Properties: map[string]string{"custom-id": "custom-ctx"},
}))
assert.Equal("custom-variant", variant.Name)

variantFromResolver := client.GetVariant("feature-name", WithVariantContext(context.Context{
Properties: map[string]string{"custom-id": "custom-ctx"},
}), WithVariantResolver(func(featureName string) *api.Feature {
if featureName == features[0].Name {
return &features[0]
} else {
t.Fatalf("the feature name passed %s was not the expected one %s", featureName, features[0].Name)
return nil
}
}))

assert.Equal("custom-variant", variantFromResolver.Name)

assert.True(gock.IsDone(), "there should be no more mocks")
}

Expand Down Expand Up @@ -381,6 +458,19 @@ func TestClient_WithSegment(t *testing.T) {

assert.True(isEnabled)

isEnabledWithResolver := client.IsEnabled(feature, WithContext(context.Context{
Properties: map[string]string{"custom-id": "custom-ctx"},
}), WithResolver(func(featureName string) *api.Feature {
if featureName == features[0].Name {
return &features[0]
} else {
t.Fatalf("the feature name passed %s was not the expected one %s", featureName, features[0].Name)
return nil
}
}))

assert.True(isEnabledWithResolver)

assert.True(gock.IsDone(), "there should be no more mocks")
}

Expand Down Expand Up @@ -447,6 +537,19 @@ func TestClient_WithNonExistingSegment(t *testing.T) {

assert.False(isEnabled)

isEnabledWithResolver := client.IsEnabled(feature, WithContext(context.Context{
Properties: map[string]string{"custom-id": "custom-ctx"},
}), WithResolver(func(featureName string) *api.Feature {
if featureName == features[0].Name {
return &features[0]
} else {
t.Fatalf("the feature name passed %s was not the expected one %s", featureName, features[0].Name)
return nil
}
}))

assert.False(isEnabledWithResolver)

assert.True(gock.IsDone(), "there should be no more mocks")
}

Expand Down Expand Up @@ -539,6 +642,19 @@ func TestClient_WithMultipleSegments(t *testing.T) {

assert.True(isEnabled)

isEnabledWithResolver := client.IsEnabled(feature, WithContext(context.Context{
Properties: map[string]string{"custom-id": "custom-ctx", "semver": "3.2.2", "age": "18", "domain": "unleashtest"},
}), WithResolver(func(featureName string) *api.Feature {
if featureName == features[0].Name {
return &features[0]
} else {
t.Fatalf("the feature name passed %s was not the expected one %s", featureName, features[0].Name)
return nil
}
}))

assert.True(isEnabledWithResolver)

assert.True(gock.IsDone(), "there should be no more mocks")
}

Expand Down Expand Up @@ -643,6 +759,19 @@ func TestClient_VariantShouldRespectConstraint(t *testing.T) {

assert.True(variant.Enabled)

variantFromResolver := client.GetVariant(feature, WithVariantContext(context.Context{
Properties: map[string]string{"custom-id": "custom-ctx", "semver": "3.2.2", "age": "18", "domain": "unleashtest"},
}), WithVariantResolver(func(featureName string) *api.Feature {
if featureName == features[0].Name {
return &features[0]
} else {
t.Fatalf("the feature name passed %s was not the expected one %s", featureName, features[0].Name)
return nil
}
}))

assert.True(variantFromResolver.Enabled)

assert.True(gock.IsDone(), "there should be no more mocks")
}

Expand Down Expand Up @@ -746,5 +875,18 @@ func TestClient_VariantShouldFailWhenSegmentConstraintsDontMatch(t *testing.T) {

assert.False(variant.Enabled)

variantFromResolver := client.GetVariant(feature, WithVariantContext(context.Context{
Properties: map[string]string{"custom-id": "custom-ctx", "semver": "3.2.2", "age": "18", "domain": "unleashtest"},
}), WithVariantResolver(func(featureName string) *api.Feature {
if featureName == features[0].Name {
return &features[0]
} else {
t.Fatalf("the feature name passed %s was not the expected one %s", featureName, features[0].Name)
return nil
}
}))

assert.False(variantFromResolver.Enabled)

assert.True(gock.IsDone(), "there should be no more mocks")
}
21 changes: 20 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func WithMetricsInterval(metricsInterval time.Duration) ConfigOption {
}
}

// WithDisabledMetrics specifies that the client should not log metrics to the unleash server.
// WithDisableMetrics specifies that the client should not log metrics to the unleash server.
func WithDisableMetrics(disableMetrics bool) ConfigOption {
return func(o *configOption) {
o.disableMetrics = disableMetrics
Expand Down Expand Up @@ -139,13 +139,24 @@ func WithProjectName(projectName string) ConfigOption {
}
}

// FeatureResolver represents a function to be called to resolve the feature instead of using the repository
type FeatureResolver func(feature string) *api.Feature

// WithResolver allows you to bypass the repository when resolving a feature name to its actual instance.
func WithResolver(resolver FeatureResolver) FeatureOption {
return func(opts *featureOption) {
opts.resolver = resolver
}
}

// FallbackFunc represents a function to be called if the feature is not found.
type FallbackFunc func(feature string, ctx *context.Context) bool

type featureOption struct {
fallback *bool
fallbackFunc FallbackFunc
ctx *context.Context
resolver FeatureResolver
}

// FeatureOption provides options for querying if a feature is enabled or not.
Expand Down Expand Up @@ -183,13 +194,21 @@ func WithVariantContext(ctx context.Context) VariantOption {
}
}

// WithVariantResolver allows you to bypass the repository when resolving a feature name to its actual instance.
func WithVariantResolver(resolver FeatureResolver) VariantOption {
return func(opts *variantOption) {
opts.resolver = resolver
}
}

// 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
ctx *context.Context
resolver FeatureResolver
}

// VariantOption provides options for querying if a variant is found or not.
Expand Down

0 comments on commit 459ff5b

Please sign in to comment.