Skip to content

Commit dde32d6

Browse files
authored
Merge pull request #7 from davejohnston/FFM-725/Implement_PreReq
(FFM-725) Implement Pre-Req Evaluation
2 parents 8e9a4f0 + 1b0dc9a commit dde32d6

File tree

7 files changed

+441
-8
lines changed

7 files changed

+441
-8
lines changed

client/client.go

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import (
1818
"github.com/drone/ff-golang-server-sdk/rest"
1919
"github.com/drone/ff-golang-server-sdk/stream"
2020
"github.com/drone/ff-golang-server-sdk/types"
21-
"github.com/hashicorp/go-retryablehttp"
21+
2222
"github.com/r3labs/sse"
2323
)
2424

@@ -43,6 +43,7 @@ type CfClient struct {
4343
cancelFunc context.CancelFunc
4444
streamConnected bool
4545
authenticated chan struct{}
46+
initialized chan bool
4647
}
4748

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

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

7476
client.persistence = cache.NewPersistence(config.Store, config.Cache, config.Logger)
7577
// load from storage
76-
if err = client.persistence.LoadFromStore(); err != nil {
77-
log.Printf("error loading from store err: %s", err)
78+
if config.enableStore {
79+
if err = client.persistence.LoadFromStore(); err != nil {
80+
log.Printf("error loading from store err: %s", err)
81+
}
7882
}
7983

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

97+
// IsInitialized determines if the client is ready to be used. This is true if it has both authenticated
98+
// and successfully retrived flags. If it takes longer than 30 seconds the call will timeout and return an error.
99+
func (c *CfClient) IsInitialized() (bool, error) {
100+
select {
101+
case <-c.initialized:
102+
return true, nil
103+
case <-time.After(30 * time.Second):
104+
break
105+
}
106+
return false, fmt.Errorf("timeout waiting to initialize")
107+
}
108+
93109
func (c *CfClient) retrieve(ctx context.Context) {
94110
// check for first cycle of cron job
95111
// for registering stream consumer
@@ -112,6 +128,7 @@ func (c *CfClient) retrieve(ctx context.Context) {
112128
}
113129
}()
114130
wg.Wait()
131+
c.initialized <- true
115132
c.config.Logger.Info("Sync run finished")
116133
}
117134

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

153-
retryClient := retryablehttp.NewClient()
154-
retryClient.RetryMax = 10
155-
156170
// dont check err just retry
157-
httpClient, err := rest.NewClientWithResponses(c.config.url, rest.WithHTTPClient(retryClient.StandardClient()))
171+
httpClient, err := rest.NewClientWithResponses(c.config.url, rest.WithHTTPClient(c.config.httpClient))
158172
if err != nil {
159173
c.config.Logger.Error(err)
160174
return
@@ -204,7 +218,7 @@ func (c *CfClient) authenticate(ctx context.Context) {
204218
}
205219
restClient, err := rest.NewClientWithResponses(c.config.url,
206220
rest.WithRequestEditorFn(bearerTokenProvider.Intercept),
207-
rest.WithHTTPClient(retryClient.StandardClient()),
221+
rest.WithHTTPClient(c.config.httpClient),
208222
)
209223
if err != nil {
210224
c.config.Logger.Error(err)
@@ -343,6 +357,11 @@ func (c *CfClient) BoolVariation(key string, target *evaluation.Target, defaultV
343357
if fc != nil {
344358
// load segments dep
345359
c.getSegmentsFromCache(fc)
360+
361+
result := checkPreRequisite(c, fc, target)
362+
if !result {
363+
return fc.Variations.FindByIdentifier(fc.OffVariation).Bool(defaultValue), nil
364+
}
346365
return fc.BoolVariation(target, defaultValue), nil
347366
}
348367
return defaultValue, nil
@@ -356,6 +375,11 @@ func (c *CfClient) StringVariation(key string, target *evaluation.Target, defaul
356375
if fc != nil {
357376
// load segments dep
358377
c.getSegmentsFromCache(fc)
378+
379+
result := checkPreRequisite(c, fc, target)
380+
if !result {
381+
return fc.Variations.FindByIdentifier(fc.OffVariation).String(defaultValue), nil
382+
}
359383
return fc.StringVariation(target, defaultValue), nil
360384
}
361385
return defaultValue, nil
@@ -369,6 +393,11 @@ func (c *CfClient) IntVariation(key string, target *evaluation.Target, defaultVa
369393
if fc != nil {
370394
// load segments dep
371395
c.getSegmentsFromCache(fc)
396+
397+
result := checkPreRequisite(c, fc, target)
398+
if !result {
399+
return fc.Variations.FindByIdentifier(fc.OffVariation).Int(defaultValue), nil
400+
}
372401
return fc.IntVariation(target, defaultValue), nil
373402
}
374403
return defaultValue, nil
@@ -382,6 +411,11 @@ func (c *CfClient) NumberVariation(key string, target *evaluation.Target, defaul
382411
if fc != nil {
383412
// load segments dep
384413
c.getSegmentsFromCache(fc)
414+
415+
result := checkPreRequisite(c, fc, target)
416+
if !result {
417+
return fc.Variations.FindByIdentifier(fc.OffVariation).Number(defaultValue), nil
418+
}
385419
return fc.NumberVariation(target, defaultValue), nil
386420
}
387421
return defaultValue, nil
@@ -396,6 +430,11 @@ func (c *CfClient) JSONVariation(key string, target *evaluation.Target, defaultV
396430
if fc != nil {
397431
// load segments dep
398432
c.getSegmentsFromCache(fc)
433+
434+
result := checkPreRequisite(c, fc, target)
435+
if !result {
436+
return fc.Variations.FindByIdentifier(fc.OffVariation).JSON(defaultValue), nil
437+
}
399438
return fc.JSONVariation(target, defaultValue), nil
400439
}
401440
return defaultValue, nil
@@ -416,3 +455,47 @@ func (c *CfClient) Close() error {
416455
func (c *CfClient) Environment() string {
417456
return c.environmentID
418457
}
458+
459+
// contains determines if the string variation is in the slice of variations.
460+
// returns true if found, otherwise false.
461+
func contains(variations []string, variation string) bool {
462+
for _, x := range variations {
463+
if x == variation {
464+
return true
465+
}
466+
}
467+
return false
468+
}
469+
470+
func checkPreRequisite(client *CfClient, featureConfig *evaluation.FeatureConfig, target *evaluation.Target) bool {
471+
result := true
472+
473+
for _, preReq := range featureConfig.Prerequisites {
474+
preReqFeature := client.getFlagFromCache(preReq.Feature)
475+
if preReqFeature == nil {
476+
client.config.Logger.Errorf("Could not retrieve the pre requisite details of feature flag :[%s]", preReq.Feature)
477+
continue
478+
}
479+
480+
// Get Variation (this performs evaluation and returns the current variation to be served to this target)
481+
preReqVariationName := preReqFeature.GetVariationName(target)
482+
preReqVariation := preReqFeature.Variations.FindByIdentifier(preReqVariationName)
483+
if preReqVariation == nil {
484+
client.config.Logger.Infof("Could not retrieve the pre requisite variation: %s", preReqVariationName)
485+
continue
486+
}
487+
client.config.Logger.Debugf("Pre requisite flag %s has variation %s for target %s", preReq.Feature, preReqVariation.Value, target.Identifier)
488+
489+
if !contains(preReq.Variations, preReqVariation.Value) {
490+
return false
491+
}
492+
493+
// Check this pre-requisites, own pre-requisite. If we get a false anywhere we need to stop
494+
result = checkPreRequisite(client, preReqFeature, target)
495+
if !result {
496+
return false
497+
}
498+
}
499+
500+
return result
501+
}

client/client_test.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package client_test
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"os"
7+
"testing"
8+
9+
"github.com/drone/ff-golang-server-sdk/client"
10+
"github.com/drone/ff-golang-server-sdk/dto"
11+
"github.com/drone/ff-golang-server-sdk/evaluation"
12+
"github.com/drone/ff-golang-server-sdk/rest"
13+
"github.com/jarcoal/httpmock"
14+
"github.com/stretchr/testify/assert"
15+
)
16+
17+
const (
18+
sdkKey = "27bed8d2-2610-462b-90eb-d80fd594b623"
19+
URL = "http://localhost/api/1.0"
20+
//nolint
21+
AuthToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9qZWN0IjoiMTA0MjM5NzYtODQ1MS00NmZjLTg2NzctYmNiZDM3MTA3M2JhIiwiZW52aXJvbm1lbnQiOiI3ZWQxMDI1ZC1hOWIxLTQxMjktYTg4Zi1lMjdlZjM2MDk4MmQiLCJwcm9qZWN0SWRlbnRpZmllciI6IiIsImVudmlyb25tZW50SWRlbnRpZmllciI6IlByZVByb2R1Y3Rpb24iLCJhY2NvdW50SUQiOiIiLCJvcmdhbml6YXRpb24iOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAifQ.z6EYSDVWwwAY6OTc2PnjSub43R6lOSJywlEObi6PDqQ"
22+
)
23+
24+
// TestMain runs before the other tests
25+
func TestMain(m *testing.M) {
26+
// httpMock overwrites the http.DefaultClient
27+
httpmock.Activate()
28+
defer httpmock.DeactivateAndReset()
29+
30+
// Register Default Responders
31+
httpmock.RegisterResponder("POST", "http://localhost/api/1.0/client/auth", ValidAuthResponse)
32+
httpmock.RegisterResponder("GET", "http://localhost/api/1.0/client/env/7ed1025d-a9b1-4129-a88f-e27ef360982d/target-segments", TargetSegmentsResponse)
33+
httpmock.RegisterResponder("GET", "http://localhost/api/1.0/client/env/7ed1025d-a9b1-4129-a88f-e27ef360982d/feature-configs", FeatureConfigsResponse)
34+
35+
os.Exit(m.Run())
36+
}
37+
38+
func TestCfClient_BoolVariation(t *testing.T) {
39+
40+
client, target, err := MakeNewClientAndTarget()
41+
if err != nil {
42+
t.Error(err)
43+
}
44+
45+
type args struct {
46+
key string
47+
target *evaluation.Target
48+
defaultValue bool
49+
}
50+
tests := []struct {
51+
name string
52+
args args
53+
want bool
54+
wantErr bool
55+
}{
56+
{"Test Invalid Flag Name returns default value", args{"MadeUpIDontExist", target, false}, false, false},
57+
{"Test Default True Flag when On returns true", args{"TestTrueOn", target, false}, true, false},
58+
{"Test Default True Flag when Off returns false", args{"TestTrueOff", target, true}, false, false},
59+
{"Test Default False Flag when On returns false", args{"TestTrueOn", target, false}, true, false},
60+
{"Test Default False Flag when Off returns true", args{"TestTrueOff", target, true}, false, false},
61+
{"Test Default True Flag when Pre-Req is False returns false", args{"TestTrueOnWithPreReqFalse", target, true}, false, false},
62+
{"Test Default True Flag when Pre-Req is True returns true", args{"TestTrueOnWithPreReqTrue", target, true}, true, false},
63+
}
64+
for _, tt := range tests {
65+
test := tt
66+
t.Run(test.name, func(t *testing.T) {
67+
flag, err := client.BoolVariation(test.args.key, test.args.target, test.args.defaultValue)
68+
if (err != nil) != test.wantErr {
69+
t.Errorf("BoolVariation() error = %v, wantErr %v", err, test.wantErr)
70+
return
71+
}
72+
assert.Equal(t, test.want, flag, "%s didn't get expected value", test.name)
73+
})
74+
}
75+
}
76+
77+
func TestCfClient_StringVariation(t *testing.T) {
78+
79+
client, target, err := MakeNewClientAndTarget()
80+
if err != nil {
81+
t.Error(err)
82+
}
83+
84+
type args struct {
85+
key string
86+
target *evaluation.Target
87+
defaultValue string
88+
}
89+
tests := []struct {
90+
name string
91+
args args
92+
want string
93+
wantErr bool
94+
}{
95+
{"Test Invalid Flag Name returns default value", args{"MadeUpIDontExist", target, "foo"}, "foo", false},
96+
{"Test Default String Flag with when On returns A", args{"TestStringAOn", target, "foo"}, "A", false},
97+
{"Test Default String Flag when Off returns B", args{"TestStringAOff", target, "foo"}, "B", false},
98+
{"Test Default String Flag when Pre-Req is False returns B", args{"TestStringAOnWithPreReqFalse", target, "foo"}, "B", false},
99+
{"Test Default String Flag when Pre-Req is True returns A", args{"TestStringAOnWithPreReqTrue", target, "foo"}, "A", false},
100+
}
101+
for _, tt := range tests {
102+
test := tt
103+
t.Run(test.name, func(t *testing.T) {
104+
flag, err := client.StringVariation(test.args.key, test.args.target, test.args.defaultValue)
105+
if (err != nil) != test.wantErr {
106+
t.Errorf("BoolVariation() error = %v, wantErr %v", err, test.wantErr)
107+
return
108+
}
109+
assert.Equal(t, test.want, flag, "%s didn't get expected value", test.name)
110+
})
111+
}
112+
}
113+
114+
// MakeNewClientAndTarget creates a new client and target. If it returns
115+
// error then something went wrong.
116+
func MakeNewClientAndTarget() (*client.CfClient, *evaluation.Target, error) {
117+
target := target()
118+
client, err := newClient(http.DefaultClient)
119+
if err != nil {
120+
return nil, nil, err
121+
}
122+
123+
// Wait to be authenticated - we can timeout if the channel doesn't return
124+
if ok, err := client.IsInitialized(); !ok {
125+
return nil, nil, err
126+
}
127+
128+
return client, target, nil
129+
}
130+
131+
// newClient creates a new client with some default options
132+
func newClient(httpClient *http.Client) (*client.CfClient, error) {
133+
return client.NewCfClient(sdkKey,
134+
client.WithURL(URL),
135+
client.WithStreamEnabled(false),
136+
client.WithHTTPClient(httpClient),
137+
client.WithStoreEnabled(false),
138+
)
139+
}
140+
141+
// target creates a new Target with some default values
142+
func target() *evaluation.Target {
143+
target := dto.NewTargetBuilder("john").
144+
Firstname("John").
145+
Lastname("Doe").
146+
147+
Build()
148+
return target
149+
}
150+
151+
var ValidAuthResponse = func(req *http.Request) (*http.Response, error) {
152+
return httpmock.NewJsonResponse(200, rest.AuthenticationResponse{
153+
AuthToken: AuthToken})
154+
}
155+
156+
var TargetSegmentsResponse = func(req *http.Request) (*http.Response, error) {
157+
var AllSegmentsResponse []rest.Segment
158+
159+
err := json.Unmarshal([]byte(`[
160+
{
161+
"environment": "PreProduction",
162+
"excluded": [],
163+
"identifier": "Beta_Users",
164+
"included": [
165+
{
166+
"identifier": "john",
167+
"name": "John",
168+
},
169+
{
170+
"identifier": "paul",
171+
"name": "Paul",
172+
}
173+
],
174+
"name": "Beta Users"
175+
}
176+
]`), &AllSegmentsResponse)
177+
if err != nil {
178+
return jsonError(err)
179+
}
180+
return httpmock.NewJsonResponse(200, AllSegmentsResponse)
181+
}
182+
183+
var FeatureConfigsResponse = func(req *http.Request) (*http.Response, error) {
184+
var FeatureConfigResponse []rest.FeatureConfig
185+
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOn", "true", "false", "on")...)
186+
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOff", "true", "false", "off")...)
187+
188+
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestFalseOn", "false", "true", "on")...)
189+
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestFalseOff", "false", "true", "off")...)
190+
191+
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOnWithPreReqFalse", "true", "false", "on", MakeBoolPreRequisite("PreReq1", "false"))...)
192+
FeatureConfigResponse = append(FeatureConfigResponse, MakeBoolFeatureConfigs("TestTrueOnWithPreReqTrue", "true", "false", "on", MakeBoolPreRequisite("PreReq1", "true"))...)
193+
194+
FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOn", "Alpha", "Bravo", "on")...)
195+
FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOff", "Alpha", "Bravo", "off")...)
196+
197+
FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOnWithPreReqFalse", "Alpha", "Bravo", "on", MakeBoolPreRequisite("PreReq1", "false"))...)
198+
FeatureConfigResponse = append(FeatureConfigResponse, MakeStringFeatureConfigs("TestStringAOnWithPreReqTrue", "Alpha", "Bravo", "on", MakeBoolPreRequisite("PreReq1", "true"))...)
199+
200+
return httpmock.NewJsonResponse(200, FeatureConfigResponse)
201+
}

0 commit comments

Comments
 (0)