Skip to content

Commit ec6639a

Browse files
authored
feat!: Support transient identities and traits (#133)
* feat: Support transient identities and traits * Add evaluation context support
1 parent 05c773f commit ec6639a

File tree

11 files changed

+331
-14
lines changed

11 files changed

+331
-14
lines changed

.golangci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
run:
22
timeout: 3m
33
modules-download-mode: readonly
4-
skip-dirs:
4+
5+
issues:
6+
exclude-dirs:
57
- sample
68

79
linters:
@@ -13,4 +15,3 @@ linters:
1315
- goimports
1416
- misspell
1517
- whitespace
16-

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.EXPORT_ALL_VARIABLES:
2+
3+
EVALUATION_CONTEXT_SCHEMA_URL ?= https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-context.json
4+
5+
6+
.PHONY: generate-evaluation-context
7+
generate-evaluation-context:
8+
npx quicktype ${EVALUATION_CONTEXT_SCHEMA_URL} --src-lang schema --lang go --package flagsmith --omit-empty --just-types-and-package > evaluationcontext.go

client.go

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@ import (
1010
"github.com/Flagsmith/flagsmith-go-client/v3/flagengine"
1111
"github.com/Flagsmith/flagsmith-go-client/v3/flagengine/environments"
1212
"github.com/Flagsmith/flagsmith-go-client/v3/flagengine/identities"
13-
. "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/identities/traits"
1413
"github.com/Flagsmith/flagsmith-go-client/v3/flagengine/segments"
1514
"github.com/go-resty/resty/v2"
15+
16+
enginetraits "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/identities/traits"
1617
)
1718

19+
type contextKey string
20+
21+
var contextKeyEvaluationContext = contextKey("evaluationContext")
22+
1823
// Client provides various methods to query Flagsmith API.
1924
type Client struct {
2025
apiKey string
@@ -34,6 +39,17 @@ type Client struct {
3439
errorHandler func(handler *FlagsmithAPIError)
3540
}
3641

42+
// Returns context with provided EvaluationContext instance set.
43+
func WithEvaluationContext(ctx context.Context, ec EvaluationContext) context.Context {
44+
return context.WithValue(ctx, contextKeyEvaluationContext, ec)
45+
}
46+
47+
// Retrieve EvaluationContext instance from context.
48+
func GetEvaluationContextFromCtx(ctx context.Context) (ec EvaluationContext, ok bool) {
49+
ec, ok = ctx.Value(contextKeyEvaluationContext).(EvaluationContext)
50+
return ec, ok
51+
}
52+
3753
// NewClient creates instance of Client with given configuration.
3854
func NewClient(apiKey string, options ...Option) *Client {
3955
c := &Client{
@@ -43,8 +59,8 @@ func NewClient(apiKey string, options ...Option) *Client {
4359
}
4460

4561
c.client.SetHeaders(map[string]string{
46-
"Accept": "application/json",
47-
"X-Environment-Key": c.apiKey,
62+
"Accept": "application/json",
63+
EnvironmentKeyHeader: c.apiKey,
4864
})
4965
c.client.SetTimeout(c.config.timeout)
5066
c.log = createLogger()
@@ -86,9 +102,34 @@ func NewClient(apiKey string, options ...Option) *Client {
86102

87103
// Returns `Flags` struct holding all the flags for the current environment.
88104
//
105+
// Provide `EvaluationContext` to evaluate flags for a specific environment or identity.
106+
//
89107
// If local evaluation is enabled this function will not call the Flagsmith API
90108
// directly, but instead read the asynchronously updated local environment or
91109
// use the default flag handler in case it has not yet been updated.
110+
//
111+
// Notes:
112+
//
113+
// * `EvaluationContext.Environment` is ignored in local evaluation mode.
114+
//
115+
// * `EvaluationContext.Feature` is not yet supported.
116+
func (c *Client) GetFlags(ctx context.Context, ec *EvaluationContext) (f Flags, err error) {
117+
if ec != nil {
118+
ctx = WithEvaluationContext(ctx, *ec)
119+
if ec.Identity != nil {
120+
return c.GetIdentityFlags(ctx, ec.Identity.Identifier, mapIdentityEvaluationContextToTraits(*ec.Identity))
121+
}
122+
}
123+
return c.GetEnvironmentFlags(ctx)
124+
}
125+
126+
// Returns `Flags` struct holding all the flags for the current environment.
127+
//
128+
// If local evaluation is enabled this function will not call the Flagsmith API
129+
// directly, but instead read the asynchronously updated local environment or
130+
// use the default flag handler in case it has not yet been updated.
131+
//
132+
// Deprecated: Use `GetFlags` instead.
92133
func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) {
93134
if c.config.localEvaluation || c.config.offlineMode {
94135
if f, err = c.getEnvironmentFlagsFromEnvironment(); err == nil {
@@ -117,6 +158,8 @@ func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) {
117158
// If local evaluation is enabled this function will not call the Flagsmith API
118159
// directly, but instead read the asynchronously updated local environment or
119160
// use the default flag handler in case it has not yet been updated.
161+
//
162+
// Deprecated: Use `GetFlags` providing `EvaluationContext.Identity` instead.
120163
func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits []*Trait) (f Flags, err error) {
121164
if c.config.localEvaluation || c.config.offlineMode {
122165
if f, err = c.getIdentityFlagsFromEnvironment(identifier, traits); err == nil {
@@ -179,7 +222,15 @@ func (c *Client) BulkIdentify(ctx context.Context, batch []*IdentityTraits) erro
179222
// GetEnvironmentFlagsFromAPI tries to contact the Flagsmith API to get the latest environment data.
180223
// Will return an error in case of failure or unexpected response.
181224
func (c *Client) GetEnvironmentFlagsFromAPI(ctx context.Context) (Flags, error) {
182-
resp, err := c.client.NewRequest().
225+
req := c.client.NewRequest()
226+
ec, ok := GetEvaluationContextFromCtx(ctx)
227+
if ok {
228+
envCtx := ec.Environment
229+
if envCtx != nil {
230+
req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey)
231+
}
232+
}
233+
resp, err := req.
183234
SetContext(ctx).
184235
ForceContentType("application/json").
185236
Get(c.config.baseURL + "flags/")
@@ -200,8 +251,22 @@ func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string,
200251
body := struct {
201252
Identifier string `json:"identifier"`
202253
Traits []*Trait `json:"traits,omitempty"`
254+
Transient *bool `json:"transient,omitempty"`
203255
}{Identifier: identifier, Traits: traits}
204-
resp, err := c.client.NewRequest().
256+
req := c.client.NewRequest()
257+
ec, ok := GetEvaluationContextFromCtx(ctx)
258+
if ok {
259+
envCtx := ec.Environment
260+
if envCtx != nil {
261+
req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey)
262+
}
263+
idCtx := ec.Identity
264+
if idCtx != nil {
265+
// `Identifier` and `Traits` had been set by `GetFlags` earlier.
266+
body.Transient = &idCtx.Transient
267+
}
268+
}
269+
resp, err := req.
205270
SetBody(&body).
206271
SetContext(ctx).
207272
ForceContentType("application/json").
@@ -302,7 +367,7 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error {
302367
}
303368

304369
func (c *Client) getIdentityModel(identifier string, apiKey string, traits []*Trait) identities.IdentityModel {
305-
identityTraits := make([]*TraitModel, len(traits))
370+
identityTraits := make([]*enginetraits.TraitModel, len(traits))
306371
for i, trait := range traits {
307372
identityTraits[i] = trait.ToTraitModel()
308373
}

client_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,27 @@ import (
1616
"github.com/stretchr/testify/assert"
1717
)
1818

19+
func getTestHttpServer(t *testing.T, expectedPath string, expectedEnvKey string, expectedRequestBody *string, responseFixture string) *httptest.Server {
20+
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
21+
assert.Equal(t, req.URL.Path, expectedPath)
22+
assert.Equal(t, expectedEnvKey, req.Header.Get("X-Environment-Key"))
23+
24+
if expectedRequestBody != nil {
25+
// Test that we sent the correct body
26+
rawBody, err := io.ReadAll(req.Body)
27+
assert.NoError(t, err)
28+
29+
assert.Equal(t, *expectedRequestBody, string(rawBody))
30+
}
31+
32+
rw.Header().Set("Content-Type", "application/json")
33+
34+
_, err := io.WriteString(rw, responseFixture)
35+
36+
assert.NoError(t, err)
37+
}))
38+
}
39+
1940
func TestClientErrorsIfLocalEvaluationWithNonServerSideKey(t *testing.T) {
2041
// When, Then
2142
assert.Panics(t, func() {
@@ -158,6 +179,135 @@ func TestClientUpdatesEnvironmentOnEachRefresh(t *testing.T) {
158179
assert.Equal(t, expectedEnvironmentRefreshCount, actualEnvironmentRefreshCounter.count)
159180
}
160181

182+
func TestGetFlags(t *testing.T) {
183+
// Given
184+
ctx := context.Background()
185+
server := getTestHttpServer(t, "/api/v1/flags/", fixtures.EnvironmentAPIKey, nil, fixtures.FlagsJson)
186+
defer server.Close()
187+
188+
// When
189+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"))
190+
191+
flags, err := client.GetFlags(ctx, nil)
192+
193+
// Then
194+
assert.NoError(t, err)
195+
196+
allFlags := flags.AllFlags()
197+
198+
assert.Equal(t, 1, len(allFlags))
199+
200+
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
201+
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
202+
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
203+
}
204+
205+
func TestGetFlagsTransientIdentity(t *testing.T) {
206+
// Given
207+
ctx := context.Background()
208+
expectedRequestBody := `{"identifier":"transient","transient":true}`
209+
server := getTestHttpServer(t, "/api/v1/identities/", fixtures.EnvironmentAPIKey, &expectedRequestBody, fixtures.IdentityResponseJson)
210+
defer server.Close()
211+
212+
// When
213+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"))
214+
215+
flags, err := client.GetFlags(ctx, &flagsmith.EvaluationContext{Identity: &flagsmith.IdentityEvaluationContext{Identifier: "transient", Transient: true}})
216+
217+
// Then
218+
assert.NoError(t, err)
219+
220+
allFlags := flags.AllFlags()
221+
222+
assert.Equal(t, 1, len(allFlags))
223+
224+
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
225+
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
226+
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
227+
}
228+
229+
func TestGetFlagsTransientTraits(t *testing.T) {
230+
// Given
231+
ctx := context.Background()
232+
expectedRequestBody := `{"identifier":"test_identity","traits":` +
233+
`[{"trait_key":"NullTrait","trait_value":null},` +
234+
`{"trait_key":"StringTrait","trait_value":"value"},` +
235+
`{"trait_key":"TransientTrait","trait_value":"value","transient":true}],"transient":false}`
236+
server := getTestHttpServer(t, "/api/v1/identities/", fixtures.EnvironmentAPIKey, &expectedRequestBody, fixtures.IdentityResponseJson)
237+
defer server.Close()
238+
239+
// When
240+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"))
241+
242+
flags, err := client.GetFlags(
243+
ctx,
244+
&flagsmith.EvaluationContext{
245+
Identity: &flagsmith.IdentityEvaluationContext{
246+
Identifier: "test_identity",
247+
Traits: map[string]*flagsmith.TraitEvaluationContext{
248+
"NullTrait": nil,
249+
"StringTrait": {Value: "value"},
250+
"TransientTrait": {
251+
Value: "value",
252+
Transient: true,
253+
},
254+
},
255+
},
256+
})
257+
258+
// Then
259+
assert.NoError(t, err)
260+
261+
allFlags := flags.AllFlags()
262+
263+
assert.Equal(t, 1, len(allFlags))
264+
265+
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
266+
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
267+
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
268+
}
269+
270+
func TestGetFlagsEnvironmentEvaluationContextFlags(t *testing.T) {
271+
// Given
272+
ctx := context.Background()
273+
expectedEnvKey := "different"
274+
server := getTestHttpServer(t, "/api/v1/flags/", expectedEnvKey, nil, fixtures.FlagsJson)
275+
defer server.Close()
276+
277+
// When
278+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"))
279+
280+
_, err := client.GetFlags(
281+
ctx,
282+
&flagsmith.EvaluationContext{
283+
Environment: &flagsmith.EnvironmentEvaluationContext{APIKey: expectedEnvKey},
284+
})
285+
286+
// Then
287+
assert.NoError(t, err)
288+
}
289+
290+
func TestGetFlagsEnvironmentEvaluationContextIdentity(t *testing.T) {
291+
// Given
292+
ctx := context.Background()
293+
expectedEnvKey := "different"
294+
server := getTestHttpServer(t, "/api/v1/identities/", expectedEnvKey, nil, fixtures.IdentityResponseJson)
295+
defer server.Close()
296+
297+
// When
298+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"))
299+
300+
_, err := client.GetFlags(
301+
ctx,
302+
&flagsmith.EvaluationContext{
303+
Environment: &flagsmith.EnvironmentEvaluationContext{APIKey: expectedEnvKey},
304+
Identity: &flagsmith.IdentityEvaluationContext{Identifier: "test_identity"},
305+
})
306+
307+
// Then
308+
assert.NoError(t, err)
309+
}
310+
161311
func TestGetEnvironmentFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) {
162312
// Given
163313
ctx := context.Background()

const.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package flagsmith
2+
3+
const EnvironmentKeyHeader = "X-Environment-Key"

evaluationcontext.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package flagsmith
2+
3+
type EvaluationContext struct {
4+
Environment *EnvironmentEvaluationContext `json:"environment,omitempty"`
5+
Feature *FeatureEvaluationContext `json:"feature,omitempty"`
6+
Identity *IdentityEvaluationContext `json:"identity,omitempty"`
7+
}
8+
9+
type EnvironmentEvaluationContext struct {
10+
APIKey string `json:"api_key"`
11+
}
12+
13+
type FeatureEvaluationContext struct {
14+
Name string `json:"name"`
15+
}
16+
17+
type IdentityEvaluationContext struct {
18+
Identifier string `json:"identifier,omitempty"`
19+
Traits map[string]*TraitEvaluationContext `json:"traits,omitempty"`
20+
Transient bool `json:"transient,omitempty"`
21+
}
22+
23+
type TraitEvaluationContext struct {
24+
Transient bool `json:"transient,omitempty"`
25+
Value interface{} `json:"value"`
26+
}

evaluationcontext_static.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package flagsmith
2+
3+
func getTraitEvaluationContext(v interface{}) TraitEvaluationContext {
4+
tCtx, ok := v.(TraitEvaluationContext)
5+
if ok {
6+
return tCtx
7+
}
8+
return TraitEvaluationContext{Value: v}
9+
}
10+
11+
func NewTraitEvaluationContext(value interface{}, transient bool) TraitEvaluationContext {
12+
return TraitEvaluationContext{Value: value, Transient: transient}
13+
}
14+
15+
func NewEvaluationContext(identifier string, traits map[string]interface{}) EvaluationContext {
16+
ec := EvaluationContext{}
17+
traitsCtx := make(map[string]*TraitEvaluationContext, len(traits))
18+
for tKey, tValue := range traits {
19+
tCtx := getTraitEvaluationContext(tValue)
20+
traitsCtx[tKey] = &tCtx
21+
}
22+
ec.Identity = &IdentityEvaluationContext{
23+
Identifier: identifier,
24+
Traits: traitsCtx,
25+
}
26+
return ec
27+
}
28+
29+
func NewTransientEvaluationContext(identifier string, traits map[string]interface{}) EvaluationContext {
30+
ec := NewEvaluationContext(identifier, traits)
31+
ec.Identity.Transient = true
32+
return ec
33+
}

0 commit comments

Comments
 (0)