Skip to content

Commit c7673ef

Browse files
author
Dave Johnston
committed
(FFM-725) Implement Pre-Req Evaluation
If a flag has pre-requisistes then they should be evaluated before returning the flag. If a pre-req is not met, then we return the offVariation for the flag.
1 parent 8e9a4f0 commit c7673ef

File tree

8 files changed

+439
-8
lines changed

8 files changed

+439
-8
lines changed

client/client.go

Lines changed: 89 additions & 7 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,16 @@ func NewCfClient(sdkKey string, options ...ConfigOption) (*CfClient, error) {
9094
return client, nil
9195
}
9296

97+
func (c *CfClient) IsInitialized() (bool, error) {
98+
select {
99+
case <-c.initialized:
100+
return true, nil
101+
case <-time.After(30*time.Second):
102+
break
103+
}
104+
return false, fmt.Errorf("Timeout waiting to initialize\n")
105+
}
106+
93107
func (c *CfClient) retrieve(ctx context.Context) {
94108
// check for first cycle of cron job
95109
// for registering stream consumer
@@ -112,6 +126,7 @@ func (c *CfClient) retrieve(ctx context.Context) {
112126
}
113127
}()
114128
wg.Wait()
129+
c.initialized<-true
115130
c.config.Logger.Info("Sync run finished")
116131
}
117132

@@ -150,11 +165,9 @@ func (c *CfClient) authenticate(ctx context.Context) {
150165
c.mux.RLock()
151166
defer c.mux.RUnlock()
152167

153-
retryClient := retryablehttp.NewClient()
154-
retryClient.RetryMax = 10
155168

156169
// dont check err just retry
157-
httpClient, err := rest.NewClientWithResponses(c.config.url, rest.WithHTTPClient(retryClient.StandardClient()))
170+
httpClient, err := rest.NewClientWithResponses(c.config.url, rest.WithHTTPClient(c.config.httpClient))
158171
if err != nil {
159172
c.config.Logger.Error(err)
160173
return
@@ -204,7 +217,7 @@ func (c *CfClient) authenticate(ctx context.Context) {
204217
}
205218
restClient, err := rest.NewClientWithResponses(c.config.url,
206219
rest.WithRequestEditorFn(bearerTokenProvider.Intercept),
207-
rest.WithHTTPClient(retryClient.StandardClient()),
220+
rest.WithHTTPClient(c.config.httpClient),
208221
)
209222
if err != nil {
210223
c.config.Logger.Error(err)
@@ -343,6 +356,11 @@ func (c *CfClient) BoolVariation(key string, target *evaluation.Target, defaultV
343356
if fc != nil {
344357
// load segments dep
345358
c.getSegmentsFromCache(fc)
359+
360+
result := checkPreRequisite(c, fc, target)
361+
if !result {
362+
return fc.Variations.FindByIdentifier(fc.OffVariation).Bool(defaultValue), nil
363+
}
346364
return fc.BoolVariation(target, defaultValue), nil
347365
}
348366
return defaultValue, nil
@@ -356,6 +374,11 @@ func (c *CfClient) StringVariation(key string, target *evaluation.Target, defaul
356374
if fc != nil {
357375
// load segments dep
358376
c.getSegmentsFromCache(fc)
377+
378+
result := checkPreRequisite(c, fc, target)
379+
if !result {
380+
return fc.Variations.FindByIdentifier(fc.OffVariation).String(defaultValue), nil
381+
}
359382
return fc.StringVariation(target, defaultValue), nil
360383
}
361384
return defaultValue, nil
@@ -369,6 +392,11 @@ func (c *CfClient) IntVariation(key string, target *evaluation.Target, defaultVa
369392
if fc != nil {
370393
// load segments dep
371394
c.getSegmentsFromCache(fc)
395+
396+
result := checkPreRequisite(c, fc, target)
397+
if !result {
398+
return fc.Variations.FindByIdentifier(fc.OffVariation).Int(defaultValue), nil
399+
}
372400
return fc.IntVariation(target, defaultValue), nil
373401
}
374402
return defaultValue, nil
@@ -382,6 +410,11 @@ func (c *CfClient) NumberVariation(key string, target *evaluation.Target, defaul
382410
if fc != nil {
383411
// load segments dep
384412
c.getSegmentsFromCache(fc)
413+
414+
result := checkPreRequisite(c, fc, target)
415+
if !result {
416+
return fc.Variations.FindByIdentifier(fc.OffVariation).Number(defaultValue), nil
417+
}
385418
return fc.NumberVariation(target, defaultValue), nil
386419
}
387420
return defaultValue, nil
@@ -396,6 +429,11 @@ func (c *CfClient) JSONVariation(key string, target *evaluation.Target, defaultV
396429
if fc != nil {
397430
// load segments dep
398431
c.getSegmentsFromCache(fc)
432+
433+
result := checkPreRequisite(c, fc, target)
434+
if !result {
435+
return fc.Variations.FindByIdentifier(fc.OffVariation).JSON(defaultValue), nil
436+
}
399437
return fc.JSONVariation(target, defaultValue), nil
400438
}
401439
return defaultValue, nil
@@ -416,3 +454,47 @@ func (c *CfClient) Close() error {
416454
func (c *CfClient) Environment() string {
417455
return c.environmentID
418456
}
457+
458+
// contains determines if the string variation is in the slice of variations.
459+
// returns true if found, otherwise false.
460+
func contains(variations []string, variation string) bool {
461+
for _, x := range variations {
462+
if x == variation {
463+
return true
464+
}
465+
}
466+
return false
467+
}
468+
469+
func checkPreRequisite(client *CfClient, featureConfig *evaluation.FeatureConfig, target *evaluation.Target) bool {
470+
result := true
471+
472+
for _, preReq := range featureConfig.Prerequisites {
473+
preReqFeature := client.getFlagFromCache(preReq.Feature)
474+
if preReqFeature == nil {
475+
client.config.Logger.Errorf("Could not retrieve the pre requisite details of feature flag :[%s]", preReq.Feature)
476+
continue
477+
}
478+
479+
// Get Variation (this performs evaluation and returns the current variation to be served to this target)
480+
preReqVariationName := preReqFeature.GetVariationName(target)
481+
preReqVariation := preReqFeature.Variations.FindByIdentifier(preReqVariationName)
482+
if preReqVariation == nil {
483+
client.config.Logger.Infof("Could not retrieve the pre requisite variation: %s", preReqVariationName)
484+
continue
485+
}
486+
client.config.Logger.Debugf("Pre requisite flag %s has variation %s for target %s", preReq.Feature, preReqVariation.Value, target.Identifier)
487+
488+
if !contains(preReq.Variations, preReqVariation.Value) {
489+
return false
490+
}
491+
492+
// Check this pre-requisites, own pre-requisite. If we get a false anywhere we need to stop
493+
result = checkPreRequisite(client, preReqFeature, target)
494+
if !result {
495+
return false
496+
}
497+
}
498+
499+
return result
500+
}

client/client_test.go

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

0 commit comments

Comments
 (0)