Skip to content

Commit

Permalink
Merge pull request #7 from davejohnston/FFM-725/Implement_PreReq
Browse files Browse the repository at this point in the history
(FFM-725) Implement Pre-Req Evaluation
  • Loading branch information
davejohnston authored Apr 27, 2021
2 parents 8e9a4f0 + 1b0dc9a commit dde32d6
Show file tree
Hide file tree
Showing 7 changed files with 441 additions and 8 deletions.
99 changes: 91 additions & 8 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"github.com/drone/ff-golang-server-sdk/rest"
"github.com/drone/ff-golang-server-sdk/stream"
"github.com/drone/ff-golang-server-sdk/types"
"github.com/hashicorp/go-retryablehttp"

"github.com/r3labs/sse"
)

Expand All @@ -43,6 +43,7 @@ type CfClient struct {
cancelFunc context.CancelFunc
streamConnected bool
authenticated chan struct{}
initialized chan bool
}

// NewCfClient creates a new client instance that connects to CF with the default configuration.
Expand All @@ -64,6 +65,7 @@ func NewCfClient(sdkKey string, options ...ConfigOption) (*CfClient, error) {
sdkKey: sdkKey,
config: config,
authenticated: make(chan struct{}),
initialized: make(chan bool),
}
ctx, client.cancelFunc = context.WithCancel(context.Background())

Expand All @@ -73,8 +75,10 @@ func NewCfClient(sdkKey string, options ...ConfigOption) (*CfClient, error) {

client.persistence = cache.NewPersistence(config.Store, config.Cache, config.Logger)
// load from storage
if err = client.persistence.LoadFromStore(); err != nil {
log.Printf("error loading from store err: %s", err)
if config.enableStore {
if err = client.persistence.LoadFromStore(); err != nil {
log.Printf("error loading from store err: %s", err)
}
}

go client.authenticate(ctx)
Expand All @@ -90,6 +94,18 @@ func NewCfClient(sdkKey string, options ...ConfigOption) (*CfClient, error) {
return client, nil
}

// IsInitialized determines if the client is ready to be used. This is true if it has both authenticated
// and successfully retrived flags. If it takes longer than 30 seconds the call will timeout and return an error.
func (c *CfClient) IsInitialized() (bool, error) {
select {
case <-c.initialized:
return true, nil
case <-time.After(30 * time.Second):
break
}
return false, fmt.Errorf("timeout waiting to initialize")
}

func (c *CfClient) retrieve(ctx context.Context) {
// check for first cycle of cron job
// for registering stream consumer
Expand All @@ -112,6 +128,7 @@ func (c *CfClient) retrieve(ctx context.Context) {
}
}()
wg.Wait()
c.initialized <- true
c.config.Logger.Info("Sync run finished")
}

Expand Down Expand Up @@ -150,11 +167,8 @@ func (c *CfClient) authenticate(ctx context.Context) {
c.mux.RLock()
defer c.mux.RUnlock()

retryClient := retryablehttp.NewClient()
retryClient.RetryMax = 10

// dont check err just retry
httpClient, err := rest.NewClientWithResponses(c.config.url, rest.WithHTTPClient(retryClient.StandardClient()))
httpClient, err := rest.NewClientWithResponses(c.config.url, rest.WithHTTPClient(c.config.httpClient))
if err != nil {
c.config.Logger.Error(err)
return
Expand Down Expand Up @@ -204,7 +218,7 @@ func (c *CfClient) authenticate(ctx context.Context) {
}
restClient, err := rest.NewClientWithResponses(c.config.url,
rest.WithRequestEditorFn(bearerTokenProvider.Intercept),
rest.WithHTTPClient(retryClient.StandardClient()),
rest.WithHTTPClient(c.config.httpClient),
)
if err != nil {
c.config.Logger.Error(err)
Expand Down Expand Up @@ -343,6 +357,11 @@ func (c *CfClient) BoolVariation(key string, target *evaluation.Target, defaultV
if fc != nil {
// load segments dep
c.getSegmentsFromCache(fc)

result := checkPreRequisite(c, fc, target)
if !result {
return fc.Variations.FindByIdentifier(fc.OffVariation).Bool(defaultValue), nil
}
return fc.BoolVariation(target, defaultValue), nil
}
return defaultValue, nil
Expand All @@ -356,6 +375,11 @@ func (c *CfClient) StringVariation(key string, target *evaluation.Target, defaul
if fc != nil {
// load segments dep
c.getSegmentsFromCache(fc)

result := checkPreRequisite(c, fc, target)
if !result {
return fc.Variations.FindByIdentifier(fc.OffVariation).String(defaultValue), nil
}
return fc.StringVariation(target, defaultValue), nil
}
return defaultValue, nil
Expand All @@ -369,6 +393,11 @@ func (c *CfClient) IntVariation(key string, target *evaluation.Target, defaultVa
if fc != nil {
// load segments dep
c.getSegmentsFromCache(fc)

result := checkPreRequisite(c, fc, target)
if !result {
return fc.Variations.FindByIdentifier(fc.OffVariation).Int(defaultValue), nil
}
return fc.IntVariation(target, defaultValue), nil
}
return defaultValue, nil
Expand All @@ -382,6 +411,11 @@ func (c *CfClient) NumberVariation(key string, target *evaluation.Target, defaul
if fc != nil {
// load segments dep
c.getSegmentsFromCache(fc)

result := checkPreRequisite(c, fc, target)
if !result {
return fc.Variations.FindByIdentifier(fc.OffVariation).Number(defaultValue), nil
}
return fc.NumberVariation(target, defaultValue), nil
}
return defaultValue, nil
Expand All @@ -396,6 +430,11 @@ func (c *CfClient) JSONVariation(key string, target *evaluation.Target, defaultV
if fc != nil {
// load segments dep
c.getSegmentsFromCache(fc)

result := checkPreRequisite(c, fc, target)
if !result {
return fc.Variations.FindByIdentifier(fc.OffVariation).JSON(defaultValue), nil
}
return fc.JSONVariation(target, defaultValue), nil
}
return defaultValue, nil
Expand All @@ -416,3 +455,47 @@ func (c *CfClient) Close() error {
func (c *CfClient) Environment() string {
return c.environmentID
}

// contains determines if the string variation is in the slice of variations.
// returns true if found, otherwise false.
func contains(variations []string, variation string) bool {
for _, x := range variations {
if x == variation {
return true
}
}
return false
}

func checkPreRequisite(client *CfClient, featureConfig *evaluation.FeatureConfig, target *evaluation.Target) bool {
result := true

for _, preReq := range featureConfig.Prerequisites {
preReqFeature := client.getFlagFromCache(preReq.Feature)
if preReqFeature == nil {
client.config.Logger.Errorf("Could not retrieve the pre requisite details of feature flag :[%s]", preReq.Feature)
continue
}

// Get Variation (this performs evaluation and returns the current variation to be served to this target)
preReqVariationName := preReqFeature.GetVariationName(target)
preReqVariation := preReqFeature.Variations.FindByIdentifier(preReqVariationName)
if preReqVariation == nil {
client.config.Logger.Infof("Could not retrieve the pre requisite variation: %s", preReqVariationName)
continue
}
client.config.Logger.Debugf("Pre requisite flag %s has variation %s for target %s", preReq.Feature, preReqVariation.Value, target.Identifier)

if !contains(preReq.Variations, preReqVariation.Value) {
return false
}

// Check this pre-requisites, own pre-requisite. If we get a false anywhere we need to stop
result = checkPreRequisite(client, preReqFeature, target)
if !result {
return false
}
}

return result
}
201 changes: 201 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package client_test

import (
"encoding/json"
"net/http"
"os"
"testing"

"github.com/drone/ff-golang-server-sdk/client"
"github.com/drone/ff-golang-server-sdk/dto"
"github.com/drone/ff-golang-server-sdk/evaluation"
"github.com/drone/ff-golang-server-sdk/rest"
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
)

const (
sdkKey = "27bed8d2-2610-462b-90eb-d80fd594b623"
URL = "http://localhost/api/1.0"
//nolint
AuthToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9qZWN0IjoiMTA0MjM5NzYtODQ1MS00NmZjLTg2NzctYmNiZDM3MTA3M2JhIiwiZW52aXJvbm1lbnQiOiI3ZWQxMDI1ZC1hOWIxLTQxMjktYTg4Zi1lMjdlZjM2MDk4MmQiLCJwcm9qZWN0SWRlbnRpZmllciI6IiIsImVudmlyb25tZW50SWRlbnRpZmllciI6IlByZVByb2R1Y3Rpb24iLCJhY2NvdW50SUQiOiIiLCJvcmdhbml6YXRpb24iOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAifQ.z6EYSDVWwwAY6OTc2PnjSub43R6lOSJywlEObi6PDqQ"
)

// TestMain runs before the other tests
func TestMain(m *testing.M) {
// httpMock overwrites the http.DefaultClient
httpmock.Activate()
defer httpmock.DeactivateAndReset()

// Register Default Responders
httpmock.RegisterResponder("POST", "http://localhost/api/1.0/client/auth", ValidAuthResponse)
httpmock.RegisterResponder("GET", "http://localhost/api/1.0/client/env/7ed1025d-a9b1-4129-a88f-e27ef360982d/target-segments", TargetSegmentsResponse)
httpmock.RegisterResponder("GET", "http://localhost/api/1.0/client/env/7ed1025d-a9b1-4129-a88f-e27ef360982d/feature-configs", FeatureConfigsResponse)

os.Exit(m.Run())
}

func TestCfClient_BoolVariation(t *testing.T) {

client, target, err := MakeNewClientAndTarget()
if err != nil {
t.Error(err)
}

type args struct {
key string
target *evaluation.Target
defaultValue bool
}
tests := []struct {
name string
args args
want bool
wantErr bool
}{
{"Test Invalid Flag Name returns default value", args{"MadeUpIDontExist", target, false}, false, false},
{"Test Default True Flag when On returns true", args{"TestTrueOn", target, false}, true, false},
{"Test Default True Flag when Off returns false", args{"TestTrueOff", target, true}, false, false},
{"Test Default False Flag when On returns false", args{"TestTrueOn", target, false}, true, false},
{"Test Default False Flag when Off returns true", args{"TestTrueOff", target, true}, false, false},
{"Test Default True Flag when Pre-Req is False returns false", args{"TestTrueOnWithPreReqFalse", target, true}, false, false},
{"Test Default True Flag when Pre-Req is True returns true", args{"TestTrueOnWithPreReqTrue", target, true}, true, false},
}
for _, tt := range tests {
test := tt
t.Run(test.name, func(t *testing.T) {
flag, err := client.BoolVariation(test.args.key, test.args.target, test.args.defaultValue)
if (err != nil) != test.wantErr {
t.Errorf("BoolVariation() error = %v, wantErr %v", err, test.wantErr)
return
}
assert.Equal(t, test.want, flag, "%s didn't get expected value", test.name)
})
}
}

func TestCfClient_StringVariation(t *testing.T) {

client, target, err := MakeNewClientAndTarget()
if err != nil {
t.Error(err)
}

type args struct {
key string
target *evaluation.Target
defaultValue string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{"Test Invalid Flag Name returns default value", args{"MadeUpIDontExist", target, "foo"}, "foo", false},
{"Test Default String Flag with when On returns A", args{"TestStringAOn", target, "foo"}, "A", false},
{"Test Default String Flag when Off returns B", args{"TestStringAOff", target, "foo"}, "B", false},
{"Test Default String Flag when Pre-Req is False returns B", args{"TestStringAOnWithPreReqFalse", target, "foo"}, "B", false},
{"Test Default String Flag when Pre-Req is True returns A", args{"TestStringAOnWithPreReqTrue", target, "foo"}, "A", false},
}
for _, tt := range tests {
test := tt
t.Run(test.name, func(t *testing.T) {
flag, err := client.StringVariation(test.args.key, test.args.target, test.args.defaultValue)
if (err != nil) != test.wantErr {
t.Errorf("BoolVariation() error = %v, wantErr %v", err, test.wantErr)
return
}
assert.Equal(t, test.want, flag, "%s didn't get expected value", test.name)
})
}
}

// MakeNewClientAndTarget creates a new client and target. If it returns
// error then something went wrong.
func MakeNewClientAndTarget() (*client.CfClient, *evaluation.Target, error) {
target := target()
client, err := newClient(http.DefaultClient)
if err != nil {
return nil, nil, err
}

// Wait to be authenticated - we can timeout if the channel doesn't return
if ok, err := client.IsInitialized(); !ok {
return nil, nil, err
}

return client, target, nil
}

// newClient creates a new client with some default options
func newClient(httpClient *http.Client) (*client.CfClient, error) {
return client.NewCfClient(sdkKey,
client.WithURL(URL),
client.WithStreamEnabled(false),
client.WithHTTPClient(httpClient),
client.WithStoreEnabled(false),
)
}

// target creates a new Target with some default values
func target() *evaluation.Target {
target := dto.NewTargetBuilder("john").
Firstname("John").
Lastname("Doe").
Email("[email protected]").
Build()
return target
}

var ValidAuthResponse = func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, rest.AuthenticationResponse{
AuthToken: AuthToken})
}

var TargetSegmentsResponse = func(req *http.Request) (*http.Response, error) {
var AllSegmentsResponse []rest.Segment

err := json.Unmarshal([]byte(`[
{
"environment": "PreProduction",
"excluded": [],
"identifier": "Beta_Users",
"included": [
{
"identifier": "john",
"name": "John",
},
{
"identifier": "paul",
"name": "Paul",
}
],
"name": "Beta Users"
}
]`), &AllSegmentsResponse)
if err != nil {
return jsonError(err)
}
return httpmock.NewJsonResponse(200, AllSegmentsResponse)
}

var FeatureConfigsResponse = func(req *http.Request) (*http.Response, error) {
var FeatureConfigResponse []rest.FeatureConfig
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOn", "true", "false", "on")...)
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOff", "true", "false", "off")...)

FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestFalseOn", "false", "true", "on")...)
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestFalseOff", "false", "true", "off")...)

FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOnWithPreReqFalse", "true", "false", "on", MakeBoolPreRequisite("PreReq1", "false"))...)
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOnWithPreReqTrue", "true", "false", "on", MakeBoolPreRequisite("PreReq1", "true"))...)

FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOn", "Alpha", "Bravo", "on")...)
FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOff", "Alpha", "Bravo", "off")...)

FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOnWithPreReqFalse", "Alpha", "Bravo", "on", MakeBoolPreRequisite("PreReq1", "false"))...)
FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOnWithPreReqTrue", "Alpha", "Bravo", "on", MakeBoolPreRequisite("PreReq1", "true"))...)

return httpmock.NewJsonResponse(200, FeatureConfigResponse)
}
Loading

0 comments on commit dde32d6

Please sign in to comment.