From bf9543ad50b7044938a9064201d502cb081f333a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 24 Jul 2024 16:47:21 +0100 Subject: [PATCH 1/9] feat: Support transient identities and traits --- client.go | 14 +++++-- client_test.go | 103 ++++++++++++++++++++++++++++++------------------- models.go | 2 + 3 files changed, 76 insertions(+), 43 deletions(-) diff --git a/client.go b/client.go index 53aefeb..7cee960 100644 --- a/client.go +++ b/client.go @@ -107,6 +107,10 @@ func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { return Flags{}, &FlagsmithClientError{msg: fmt.Sprintf("Failed to fetch flags with error: %s", err)} } +type GetIdentityFlagsOpts struct { + Transient bool `json:"transient,omitempty"` +} + // Returns `Flags` struct holding all the flags for the current environment for // a given identity. // @@ -117,13 +121,13 @@ func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { // If local evaluation is enabled this function will not call the Flagsmith API // directly, but instead read the asynchronously updated local environment or // use the default flag handler in case it has not yet been updated. -func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits []*Trait) (f Flags, err error) { +func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits []*Trait, opts *GetIdentityFlagsOpts) (f Flags, err error) { if c.config.localEvaluation || c.config.offlineMode { if f, err = c.getIdentityFlagsFromEnvironment(identifier, traits); err == nil { return f, nil } } else { - if f, err = c.GetIdentityFlagsFromAPI(ctx, identifier, traits); err == nil { + if f, err = c.GetIdentityFlagsFromAPI(ctx, identifier, traits, opts); err == nil { return f, nil } } @@ -196,11 +200,15 @@ func (c *Client) GetEnvironmentFlagsFromAPI(ctx context.Context) (Flags, error) // GetIdentityFlagsFromAPI tries to contact the Flagsmith API to get the latest identity flags. // Will return an error in case of failure or unexpected response. -func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, traits []*Trait) (Flags, error) { +func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, traits []*Trait, opts *GetIdentityFlagsOpts) (Flags, error) { body := struct { Identifier string `json:"identifier"` Traits []*Trait `json:"traits,omitempty"` + GetIdentityFlagsOpts }{Identifier: identifier, Traits: traits} + if opts != nil { + body.Transient = opts.Transient + } resp, err := c.client.NewRequest(). SetBody(&body). SetContext(ctx). diff --git a/client_test.go b/client_test.go index cb732ad..27b37d3 100644 --- a/client_test.go +++ b/client_test.go @@ -245,7 +245,7 @@ func TestGetIdentityFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) { // Then assert.NoError(t, err) - flags, err := client.GetIdentityFlags(ctx, "test_identity", nil) + flags, err := client.GetIdentityFlags(ctx, "test_identity", nil, nil) assert.NoError(t, err) @@ -271,7 +271,7 @@ func TestGetIdentityFlagsUseslocalOverridesWhenAvailable(t *testing.T) { // Then assert.NoError(t, err) - flags, err := client.GetIdentityFlags(ctx, "overridden-id", nil) + flags, err := client.GetIdentityFlags(ctx, "overridden-id", nil, nil) assert.NoError(t, err) @@ -286,55 +286,78 @@ func TestGetIdentityFlagsUseslocalOverridesWhenAvailable(t *testing.T) { func TestGetIdentityFlagsCallsAPIWhenLocalEnvironmentNotAvailableWithTraits(t *testing.T) { // Given + stringTrait := flagsmith.Trait{TraitKey: "stringTrait", TraitValue: "trait_value"} + intTrait := flagsmith.Trait{TraitKey: "intTrait", TraitValue: 1} + floatTrait := flagsmith.Trait{TraitKey: "floatTrait", TraitValue: 1.11} + boolTrait := flagsmith.Trait{TraitKey: "boolTrait", TraitValue: true} + nillTrait := flagsmith.Trait{TraitKey: "NoneTrait", TraitValue: nil} + transientTrait := flagsmith.Trait{TraitKey: "TransientTrait", TraitValue: "not_persisted", Transient: true} + + testCases := []struct { + Identifier string + Traits []*flagsmith.Trait + Opts *flagsmith.GetIdentityFlagsOpts + ExpectedRequestBody string + }{ + { + "test_identity", + []*flagsmith.Trait{&stringTrait, &intTrait, &floatTrait, &boolTrait, &nillTrait, &transientTrait}, + nil, + `{"identifier":"test_identity","traits":[{"trait_key":"stringTrait","trait_value":"trait_value"},` + + `{"trait_key":"intTrait","trait_value":1},` + + `{"trait_key":"floatTrait","trait_value":1.11},` + + `{"trait_key":"boolTrait","trait_value":true},` + + `{"trait_key":"NoneTrait","trait_value":null},` + + `{"trait_key":"TransientTrait","trait_value":"not_persisted","transient":true}]}`, + }, + { + "test_transient_identity", + []*flagsmith.Trait{}, + &flagsmith.GetIdentityFlagsOpts{Transient: true}, + `{"identifier":"test_transient_identity","transient":true}`, + }, + } + ctx := context.Background() - expectedRequestBody := `{"identifier":"test_identity","traits":[{"trait_key":"stringTrait","trait_value":"trait_value"},` + - `{"trait_key":"intTrait","trait_value":1},` + - `{"trait_key":"floatTrait","trait_value":1.11},` + - `{"trait_key":"boolTrait","trait_value":true},` + - `{"trait_key":"NoneTrait","trait_value":null}]}` - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - assert.Equal(t, req.URL.Path, "/api/v1/identities/") - assert.Equal(t, fixtures.EnvironmentAPIKey, req.Header.Get("X-Environment-Key")) + for _, tc := range testCases { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, req.URL.Path, "/api/v1/identities/") + assert.Equal(t, fixtures.EnvironmentAPIKey, req.Header.Get("X-Environment-Key")) - // Test that we sent the correct body - rawBody, err := io.ReadAll(req.Body) - assert.NoError(t, err) - assert.Equal(t, expectedRequestBody, string(rawBody)) + // Test that we sent the correct body + rawBody, err := io.ReadAll(req.Body) + assert.NoError(t, err) + assert.Equal(t, tc.ExpectedRequestBody, string(rawBody)) - rw.Header().Set("Content-Type", "application/json") + rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusOK) - _, err = io.WriteString(rw, fixtures.IdentityResponseJson) + rw.WriteHeader(http.StatusOK) + _, err = io.WriteString(rw, fixtures.IdentityResponseJson) - assert.NoError(t, err) - })) - defer server.Close() - // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, - flagsmith.WithBaseURL(server.URL+"/api/v1/")) + assert.NoError(t, err) + })) + defer server.Close() + // When + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, + flagsmith.WithBaseURL(server.URL+"/api/v1/")) - stringTrait := flagsmith.Trait{TraitKey: "stringTrait", TraitValue: "trait_value"} - intTrait := flagsmith.Trait{TraitKey: "intTrait", TraitValue: 1} - floatTrait := flagsmith.Trait{TraitKey: "floatTrait", TraitValue: 1.11} - boolTrait := flagsmith.Trait{TraitKey: "boolTrait", TraitValue: true} - nillTrait := flagsmith.Trait{TraitKey: "NoneTrait", TraitValue: nil} + // When - traits := []*flagsmith.Trait{&stringTrait, &intTrait, &floatTrait, &boolTrait, &nillTrait} - // When + flags, err := client.GetIdentityFlags(ctx, tc.Identifier, tc.Traits, tc.Opts) - flags, err := client.GetIdentityFlags(ctx, "test_identity", traits) + // Then + assert.NoError(t, err) - // Then - assert.NoError(t, err) + allFlags := flags.AllFlags() - allFlags := flags.AllFlags() + assert.Equal(t, 1, len(allFlags)) - assert.Equal(t, 1, len(allFlags)) + assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) + assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) + assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) - assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) - assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) - assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) + } } func TestDefaultHandlerIsUsedWhenNoMatchingEnvironmentFlagReturned(t *testing.T) { @@ -622,7 +645,7 @@ func TestOfflineMode(t *testing.T) { assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) // And GetIdentityFlags works as well - flags, err = client.GetIdentityFlags(ctx, "test_identity", nil) + flags, err = client.GetIdentityFlags(ctx, "test_identity", nil, nil) assert.NoError(t, err) allFlags = flags.AllFlags() @@ -664,7 +687,7 @@ func TestOfflineHandlerIsUsedWhenRequestFails(t *testing.T) { assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) // And GetIdentityFlags works as well - flags, err = client.GetIdentityFlags(ctx, "test_identity", nil) + flags, err = client.GetIdentityFlags(ctx, "test_identity", nil, nil) assert.NoError(t, err) allFlags = flags.AllFlags() diff --git a/models.go b/models.go index a84250d..490e547 100644 --- a/models.go +++ b/models.go @@ -20,11 +20,13 @@ type Flag struct { type Trait struct { TraitKey string `json:"trait_key"` TraitValue interface{} `json:"trait_value"` + Transient bool `json:"transient,omitempty"` } type IdentityTraits struct { Identifier string `json:"identifier"` Traits []*Trait `json:"traits"` + Transient bool `json:"transient,omitempty"` } func (t *Trait) ToTraitModel() *traits.TraitModel { From de931820efb0236b64a2d883495606ae278c4ea5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 24 Jul 2024 16:58:17 +0100 Subject: [PATCH 2/9] Update lint config --- .golangci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index ea432d1..ff6c7d7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,9 @@ run: timeout: 3m modules-download-mode: readonly - skip-dirs: + +issues: + exclude-dirs: - sample linters: @@ -13,4 +15,3 @@ linters: - goimports - misspell - whitespace - From 89b9af381bc8489e371e0f2986d58839080766b4 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 24 Jul 2024 17:00:46 +0100 Subject: [PATCH 3/9] linting --- client_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/client_test.go b/client_test.go index 27b37d3..674a1c5 100644 --- a/client_test.go +++ b/client_test.go @@ -356,7 +356,6 @@ func TestGetIdentityFlagsCallsAPIWhenLocalEnvironmentNotAvailableWithTraits(t *t assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) - } } From 4ffddd6f8e547a4bfb433bf20a227f0161b56020 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 2 Aug 2024 17:56:26 +0100 Subject: [PATCH 4/9] Add evaluation context support --- Makefile | 8 ++ client.go | 79 ++++++++--- client_test.go | 254 +++++++++++++++++++++++++++--------- const.go | 3 + evaluationcontext.go | 26 ++++ evaluationcontext_static.go | 33 +++++ utils.go | 31 +++++ 7 files changed, 355 insertions(+), 79 deletions(-) create mode 100644 Makefile create mode 100644 const.go create mode 100644 evaluationcontext.go create mode 100644 evaluationcontext_static.go create mode 100644 utils.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1ea9c02 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.EXPORT_ALL_VARIABLES: + +EVALUATION_CONTEXT_SCHEMA_URL ?= https://raw.githubusercontent.com/Flagsmith/flagsmith/feat/evaluation-context-schema/sdk/evaluation-context.json + + +.PHONY: generate-evaluation-context +generate-evaluation-context: + npx quicktype ${EVALUATION_CONTEXT_SCHEMA_URL} --src-lang schema --lang go --package flagsmith --omit-empty --just-types-and-package > evaluationcontext.go diff --git a/client.go b/client.go index 7cee960..2064b1f 100644 --- a/client.go +++ b/client.go @@ -10,11 +10,16 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v3/flagengine" "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/environments" "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/identities" - . "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/identities/traits" "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/segments" "github.com/go-resty/resty/v2" + + enginetraits "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/identities/traits" ) +type contextKey string + +var contextKeyEvaluationContext = contextKey("evaluationContext") + // Client provides various methods to query Flagsmith API. type Client struct { apiKey string @@ -43,8 +48,8 @@ func NewClient(apiKey string, options ...Option) *Client { } c.client.SetHeaders(map[string]string{ - "Accept": "application/json", - "X-Environment-Key": c.apiKey, + "Accept": "application/json", + EnvironmentKeyHeader: c.apiKey, }) c.client.SetTimeout(c.config.timeout) c.log = createLogger() @@ -84,11 +89,36 @@ func NewClient(apiKey string, options ...Option) *Client { return c } +// Returns `Flags` struct holding all the flags for the current environment. +// +// Provide `EvaluationContext` to evaluate flags for a specific environment or identity. +// +// If local evaluation is enabled this function will not call the Flagsmith API +// directly, but instead read the asynchronously updated local environment or +// use the default flag handler in case it has not yet been updated. +// +// Notes: +// +// * `EvaluationContext.Environment` is ignored in local evaluation mode. +// +// * `EvaluationContext.Feature` is not yet supported. +func (c *Client) GetFlags(ctx context.Context, ec *EvaluationContext) (f Flags, err error) { + if ec != nil { + ctx = context.WithValue(ctx, contextKeyEvaluationContext, ec) + if ec.Identity != nil { + return c.GetIdentityFlags(ctx, ec.Identity.Identifier, mapIdentityEvaluationContextToTraits(*ec.Identity)) + } + } + return c.GetEnvironmentFlags(ctx) +} + // Returns `Flags` struct holding all the flags for the current environment. // // If local evaluation is enabled this function will not call the Flagsmith API // directly, but instead read the asynchronously updated local environment or // use the default flag handler in case it has not yet been updated. +// +// Deprecated: Use `GetFlags` instead. func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { if c.config.localEvaluation || c.config.offlineMode { if f, err = c.getEnvironmentFlagsFromEnvironment(); err == nil { @@ -107,10 +137,6 @@ func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { return Flags{}, &FlagsmithClientError{msg: fmt.Sprintf("Failed to fetch flags with error: %s", err)} } -type GetIdentityFlagsOpts struct { - Transient bool `json:"transient,omitempty"` -} - // Returns `Flags` struct holding all the flags for the current environment for // a given identity. // @@ -121,13 +147,15 @@ type GetIdentityFlagsOpts struct { // If local evaluation is enabled this function will not call the Flagsmith API // directly, but instead read the asynchronously updated local environment or // use the default flag handler in case it has not yet been updated. -func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits []*Trait, opts *GetIdentityFlagsOpts) (f Flags, err error) { +// +// Deprecated: Use `GetFlags` providing `EvaluationContext.Identity` instead. +func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits []*Trait) (f Flags, err error) { if c.config.localEvaluation || c.config.offlineMode { if f, err = c.getIdentityFlagsFromEnvironment(identifier, traits); err == nil { return f, nil } } else { - if f, err = c.GetIdentityFlagsFromAPI(ctx, identifier, traits, opts); err == nil { + if f, err = c.GetIdentityFlagsFromAPI(ctx, identifier, traits); err == nil { return f, nil } } @@ -183,7 +211,15 @@ func (c *Client) BulkIdentify(ctx context.Context, batch []*IdentityTraits) erro // GetEnvironmentFlagsFromAPI tries to contact the Flagsmith API to get the latest environment data. // Will return an error in case of failure or unexpected response. func (c *Client) GetEnvironmentFlagsFromAPI(ctx context.Context) (Flags, error) { - resp, err := c.client.NewRequest(). + req := c.client.NewRequest() + maybeEc := ctx.Value(contextKeyEvaluationContext) + if maybeEc != nil { + envCtx := maybeEc.(*EvaluationContext).Environment + if envCtx != nil { + req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey) + } + } + resp, err := req. SetContext(ctx). ForceContentType("application/json"). Get(c.config.baseURL + "flags/") @@ -200,16 +236,27 @@ func (c *Client) GetEnvironmentFlagsFromAPI(ctx context.Context) (Flags, error) // GetIdentityFlagsFromAPI tries to contact the Flagsmith API to get the latest identity flags. // Will return an error in case of failure or unexpected response. -func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, traits []*Trait, opts *GetIdentityFlagsOpts) (Flags, error) { +func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, traits []*Trait) (Flags, error) { body := struct { Identifier string `json:"identifier"` Traits []*Trait `json:"traits,omitempty"` - GetIdentityFlagsOpts + Transient *bool `json:"transient,omitempty"` }{Identifier: identifier, Traits: traits} - if opts != nil { - body.Transient = opts.Transient + req := c.client.NewRequest() + maybeEc := ctx.Value(contextKeyEvaluationContext) + if maybeEc != nil { + ec := maybeEc.(*EvaluationContext) + envCtx := ec.Environment + if envCtx != nil { + req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey) + } + idCtx := ec.Identity + if idCtx != nil { + // `Identifier` and `Traits` had been set by `GetFlags` earlier. + body.Transient = &idCtx.Transient + } } - resp, err := c.client.NewRequest(). + resp, err := req. SetBody(&body). SetContext(ctx). ForceContentType("application/json"). @@ -310,7 +357,7 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { } func (c *Client) getIdentityModel(identifier string, apiKey string, traits []*Trait) identities.IdentityModel { - identityTraits := make([]*TraitModel, len(traits)) + identityTraits := make([]*enginetraits.TraitModel, len(traits)) for i, trait := range traits { identityTraits[i] = trait.ToTraitModel() } diff --git a/client_test.go b/client_test.go index 674a1c5..e8a9163 100644 --- a/client_test.go +++ b/client_test.go @@ -16,6 +16,27 @@ import ( "github.com/stretchr/testify/assert" ) +func getTestHttpServer(t *testing.T, expectedPath string, expectedEnvKey string, expectedRequestBody *string, responseFixture string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, req.URL.Path, expectedPath) + assert.Equal(t, expectedEnvKey, req.Header.Get("X-Environment-Key")) + + if expectedRequestBody != nil { + // Test that we sent the correct body + rawBody, err := io.ReadAll(req.Body) + assert.NoError(t, err) + + assert.Equal(t, *expectedRequestBody, string(rawBody)) + } + + rw.Header().Set("Content-Type", "application/json") + + _, err := io.WriteString(rw, responseFixture) + + assert.NoError(t, err) + })) +} + func TestClientErrorsIfLocalEvaluationWithNonServerSideKey(t *testing.T) { // When, Then assert.Panics(t, func() { @@ -158,6 +179,135 @@ func TestClientUpdatesEnvironmentOnEachRefresh(t *testing.T) { assert.Equal(t, expectedEnvironmentRefreshCount, actualEnvironmentRefreshCounter.count) } +func TestGetFlags(t *testing.T) { + // Given + ctx := context.Background() + server := getTestHttpServer(t, "/api/v1/flags/", fixtures.EnvironmentAPIKey, nil, fixtures.FlagsJson) + defer server.Close() + + // When + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + + flags, err := client.GetFlags(ctx, nil) + + // Then + assert.NoError(t, err) + + allFlags := flags.AllFlags() + + assert.Equal(t, 1, len(allFlags)) + + assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) + assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) + assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) +} + +func TestGetFlagsTransientIdentity(t *testing.T) { + // Given + ctx := context.Background() + expectedRequestBody := `{"identifier":"transient","transient":true}` + server := getTestHttpServer(t, "/api/v1/identities/", fixtures.EnvironmentAPIKey, &expectedRequestBody, fixtures.IdentityResponseJson) + defer server.Close() + + // When + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + + flags, err := client.GetFlags(ctx, &flagsmith.EvaluationContext{Identity: &flagsmith.IdentityEvaluationContext{Identifier: "transient", Transient: true}}) + + // Then + assert.NoError(t, err) + + allFlags := flags.AllFlags() + + assert.Equal(t, 1, len(allFlags)) + + assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) + assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) + assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) +} + +func TestGetFlagsTransientTraits(t *testing.T) { + // Given + ctx := context.Background() + expectedRequestBody := `{"identifier":"test_identity","traits":` + + `[{"trait_key":"NullTrait","trait_value":null},` + + `{"trait_key":"StringTrait","trait_value":"value"},` + + `{"trait_key":"TransientTrait","trait_value":"value","transient":true}],"transient":false}` + server := getTestHttpServer(t, "/api/v1/identities/", fixtures.EnvironmentAPIKey, &expectedRequestBody, fixtures.IdentityResponseJson) + defer server.Close() + + // When + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + + flags, err := client.GetFlags( + ctx, + &flagsmith.EvaluationContext{ + Identity: &flagsmith.IdentityEvaluationContext{ + Identifier: "test_identity", + Traits: map[string]*flagsmith.TraitEvaluationContext{ + "NullTrait": nil, + "StringTrait": {Value: "value"}, + "TransientTrait": { + Value: "value", + Transient: true, + }, + }, + }, + }) + + // Then + assert.NoError(t, err) + + allFlags := flags.AllFlags() + + assert.Equal(t, 1, len(allFlags)) + + assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) + assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) + assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) +} + +func TestGetFlagsEnvironmentEvaluationContextFlags(t *testing.T) { + // Given + ctx := context.Background() + expectedEnvKey := "different" + server := getTestHttpServer(t, "/api/v1/flags/", expectedEnvKey, nil, fixtures.FlagsJson) + defer server.Close() + + // When + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + + _, err := client.GetFlags( + ctx, + &flagsmith.EvaluationContext{ + Environment: &flagsmith.EnvironmentEvaluationContext{APIKey: expectedEnvKey}, + }) + + // Then + assert.NoError(t, err) +} + +func TestGetFlagsEnvironmentEvaluationContextIdentity(t *testing.T) { + // Given + ctx := context.Background() + expectedEnvKey := "different" + server := getTestHttpServer(t, "/api/v1/identities/", expectedEnvKey, nil, fixtures.IdentityResponseJson) + defer server.Close() + + // When + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + + _, err := client.GetFlags( + ctx, + &flagsmith.EvaluationContext{ + Environment: &flagsmith.EnvironmentEvaluationContext{APIKey: expectedEnvKey}, + Identity: &flagsmith.IdentityEvaluationContext{Identifier: "test_identity"}, + }) + + // Then + assert.NoError(t, err) +} + func TestGetEnvironmentFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) { // Given ctx := context.Background() @@ -245,7 +395,7 @@ func TestGetIdentityFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) { // Then assert.NoError(t, err) - flags, err := client.GetIdentityFlags(ctx, "test_identity", nil, nil) + flags, err := client.GetIdentityFlags(ctx, "test_identity", nil) assert.NoError(t, err) @@ -271,7 +421,7 @@ func TestGetIdentityFlagsUseslocalOverridesWhenAvailable(t *testing.T) { // Then assert.NoError(t, err) - flags, err := client.GetIdentityFlags(ctx, "overridden-id", nil, nil) + flags, err := client.GetIdentityFlags(ctx, "overridden-id", nil) assert.NoError(t, err) @@ -286,77 +436,55 @@ func TestGetIdentityFlagsUseslocalOverridesWhenAvailable(t *testing.T) { func TestGetIdentityFlagsCallsAPIWhenLocalEnvironmentNotAvailableWithTraits(t *testing.T) { // Given - stringTrait := flagsmith.Trait{TraitKey: "stringTrait", TraitValue: "trait_value"} - intTrait := flagsmith.Trait{TraitKey: "intTrait", TraitValue: 1} - floatTrait := flagsmith.Trait{TraitKey: "floatTrait", TraitValue: 1.11} - boolTrait := flagsmith.Trait{TraitKey: "boolTrait", TraitValue: true} - nillTrait := flagsmith.Trait{TraitKey: "NoneTrait", TraitValue: nil} - transientTrait := flagsmith.Trait{TraitKey: "TransientTrait", TraitValue: "not_persisted", Transient: true} - - testCases := []struct { - Identifier string - Traits []*flagsmith.Trait - Opts *flagsmith.GetIdentityFlagsOpts - ExpectedRequestBody string - }{ - { - "test_identity", - []*flagsmith.Trait{&stringTrait, &intTrait, &floatTrait, &boolTrait, &nillTrait, &transientTrait}, - nil, - `{"identifier":"test_identity","traits":[{"trait_key":"stringTrait","trait_value":"trait_value"},` + - `{"trait_key":"intTrait","trait_value":1},` + - `{"trait_key":"floatTrait","trait_value":1.11},` + - `{"trait_key":"boolTrait","trait_value":true},` + - `{"trait_key":"NoneTrait","trait_value":null},` + - `{"trait_key":"TransientTrait","trait_value":"not_persisted","transient":true}]}`, - }, - { - "test_transient_identity", - []*flagsmith.Trait{}, - &flagsmith.GetIdentityFlagsOpts{Transient: true}, - `{"identifier":"test_transient_identity","transient":true}`, - }, - } - ctx := context.Background() + expectedRequestBody := `{"identifier":"test_identity","traits":[{"trait_key":"stringTrait","trait_value":"trait_value"},` + + `{"trait_key":"intTrait","trait_value":1},` + + `{"trait_key":"floatTrait","trait_value":1.11},` + + `{"trait_key":"boolTrait","trait_value":true},` + + `{"trait_key":"NoneTrait","trait_value":null}]}` - for _, tc := range testCases { - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - assert.Equal(t, req.URL.Path, "/api/v1/identities/") - assert.Equal(t, fixtures.EnvironmentAPIKey, req.Header.Get("X-Environment-Key")) + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, req.URL.Path, "/api/v1/identities/") + assert.Equal(t, fixtures.EnvironmentAPIKey, req.Header.Get("X-Environment-Key")) - // Test that we sent the correct body - rawBody, err := io.ReadAll(req.Body) - assert.NoError(t, err) - assert.Equal(t, tc.ExpectedRequestBody, string(rawBody)) + // Test that we sent the correct body + rawBody, err := io.ReadAll(req.Body) + assert.NoError(t, err) + assert.Equal(t, expectedRequestBody, string(rawBody)) - rw.Header().Set("Content-Type", "application/json") + rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusOK) - _, err = io.WriteString(rw, fixtures.IdentityResponseJson) + rw.WriteHeader(http.StatusOK) + _, err = io.WriteString(rw, fixtures.IdentityResponseJson) - assert.NoError(t, err) - })) - defer server.Close() - // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, - flagsmith.WithBaseURL(server.URL+"/api/v1/")) + assert.NoError(t, err) + })) + defer server.Close() + // When + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, + flagsmith.WithBaseURL(server.URL+"/api/v1/")) - // When + stringTrait := flagsmith.Trait{TraitKey: "stringTrait", TraitValue: "trait_value"} + intTrait := flagsmith.Trait{TraitKey: "intTrait", TraitValue: 1} + floatTrait := flagsmith.Trait{TraitKey: "floatTrait", TraitValue: 1.11} + boolTrait := flagsmith.Trait{TraitKey: "boolTrait", TraitValue: true} + nillTrait := flagsmith.Trait{TraitKey: "NoneTrait", TraitValue: nil} - flags, err := client.GetIdentityFlags(ctx, tc.Identifier, tc.Traits, tc.Opts) + traits := []*flagsmith.Trait{&stringTrait, &intTrait, &floatTrait, &boolTrait, &nillTrait} + // When - // Then - assert.NoError(t, err) + flags, err := client.GetIdentityFlags(ctx, "test_identity", traits) - allFlags := flags.AllFlags() + // Then + assert.NoError(t, err) - assert.Equal(t, 1, len(allFlags)) + allFlags := flags.AllFlags() - assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) - assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) - assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) - } + assert.Equal(t, 1, len(allFlags)) + + assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName) + assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) + assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) } func TestDefaultHandlerIsUsedWhenNoMatchingEnvironmentFlagReturned(t *testing.T) { @@ -644,7 +772,7 @@ func TestOfflineMode(t *testing.T) { assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) // And GetIdentityFlags works as well - flags, err = client.GetIdentityFlags(ctx, "test_identity", nil, nil) + flags, err = client.GetIdentityFlags(ctx, "test_identity", nil) assert.NoError(t, err) allFlags = flags.AllFlags() @@ -686,7 +814,7 @@ func TestOfflineHandlerIsUsedWhenRequestFails(t *testing.T) { assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) // And GetIdentityFlags works as well - flags, err = client.GetIdentityFlags(ctx, "test_identity", nil, nil) + flags, err = client.GetIdentityFlags(ctx, "test_identity", nil) assert.NoError(t, err) allFlags = flags.AllFlags() diff --git a/const.go b/const.go new file mode 100644 index 0000000..2d3d26e --- /dev/null +++ b/const.go @@ -0,0 +1,3 @@ +package flagsmith + +const EnvironmentKeyHeader = "X-Environment-Key" diff --git a/evaluationcontext.go b/evaluationcontext.go new file mode 100644 index 0000000..b166583 --- /dev/null +++ b/evaluationcontext.go @@ -0,0 +1,26 @@ +package flagsmith + +type EvaluationContext struct { + Environment *EnvironmentEvaluationContext `json:"environment,omitempty"` + Feature *FeatureEvaluationContext `json:"feature,omitempty"` + Identity *IdentityEvaluationContext `json:"identity,omitempty"` +} + +type EnvironmentEvaluationContext struct { + APIKey string `json:"api_key"` +} + +type FeatureEvaluationContext struct { + Name string `json:"name"` +} + +type IdentityEvaluationContext struct { + Identifier string `json:"identifier"` + Traits map[string]*TraitEvaluationContext `json:"traits"` + Transient bool `json:"transient,omitempty"` +} + +type TraitEvaluationContext struct { + Transient bool `json:"transient,omitempty"` + Value interface{} `json:"value"` +} diff --git a/evaluationcontext_static.go b/evaluationcontext_static.go new file mode 100644 index 0000000..ec1f356 --- /dev/null +++ b/evaluationcontext_static.go @@ -0,0 +1,33 @@ +package flagsmith + +func getTraitEvaluationContext(v interface{}) TraitEvaluationContext { + tCtx, ok := v.(TraitEvaluationContext) + if ok { + return tCtx + } + return TraitEvaluationContext{Value: v} +} + +func NewTraitEvaluationContext(value interface{}, transient bool) TraitEvaluationContext { + return TraitEvaluationContext{Value: value, Transient: transient} +} + +func NewEvaluationContext(identifier string, traits map[string]interface{}) EvaluationContext { + ec := EvaluationContext{} + traitsCtx := make(map[string]*TraitEvaluationContext, len(traits)) + for tKey, tValue := range traits { + tCtx := getTraitEvaluationContext(tValue) + traitsCtx[tKey] = &tCtx + } + ec.Identity = &IdentityEvaluationContext{ + Identifier: identifier, + Traits: traitsCtx, + } + return ec +} + +func NewTransientEvaluationContext(identifier string, traits map[string]interface{}) EvaluationContext { + ec := NewEvaluationContext(identifier, traits) + ec.Identity.Transient = true + return ec +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..3408a56 --- /dev/null +++ b/utils.go @@ -0,0 +1,31 @@ +package flagsmith + +import ( + "sort" +) + +func mapIdentityEvaluationContextToTraits(ic IdentityEvaluationContext) []*Trait { + traits := make([]*Trait, len(ic.Traits)) + for i, tKey := range sortedKeys(ic.Traits) { + traits[i] = mapTraitEvaluationContextToTrait(tKey, ic.Traits[tKey]) + } + return traits +} + +func mapTraitEvaluationContextToTrait(tKey string, tCtx *TraitEvaluationContext) *Trait { + if tCtx == nil { + return &Trait{TraitKey: tKey, TraitValue: nil} + } + return &Trait{TraitKey: tKey, TraitValue: tCtx.Value, Transient: tCtx.Transient} +} + +func sortedKeys[Map ~map[string]V, V any](m Map) []string { + keys := make([]string, len(m)) + i := 0 + for tKey := range m { + keys[i] = tKey + i++ + } + sort.Strings(keys) + return keys +} From 58b8f6a6b3a6760fdb83e1efe61cc940ae9c6a67 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 9 Aug 2024 17:20:40 +0100 Subject: [PATCH 5/9] update schema --- evaluationcontext.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evaluationcontext.go b/evaluationcontext.go index b166583..0ce7776 100644 --- a/evaluationcontext.go +++ b/evaluationcontext.go @@ -15,8 +15,8 @@ type FeatureEvaluationContext struct { } type IdentityEvaluationContext struct { - Identifier string `json:"identifier"` - Traits map[string]*TraitEvaluationContext `json:"traits"` + Identifier string `json:"identifier,omitempty"` + Traits map[string]*TraitEvaluationContext `json:"traits,omitempty"` Transient bool `json:"transient,omitempty"` } From 248bd2a60f878bb6913c8d7421dbd03fe6f3767c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 9 Aug 2024 23:36:55 +0100 Subject: [PATCH 6/9] fix typo, try stuff --- flagengine/engine_test.go | 4 ++-- flagengine/utils/fixtures/fixtures.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flagengine/engine_test.go b/flagengine/engine_test.go index 7e271a2..e8b3697 100644 --- a/flagengine/engine_test.go +++ b/flagengine/engine_test.go @@ -101,14 +101,14 @@ func TestIdentityGetAllFeatureStatesSegmentsOnly(t *testing.T) { } func TestIdentityGetAllFeatureStatesWithTraits(t *testing.T) { - t.Parallel() + // t.Parallel() feature1, _, segment, env, identity := fixtures.GetFixtures() envWithSegmentOverride := fixtures.EnvironmentWithSegmentOverride(env, fixtures.SegmentOverrideFs(segment, feature1), segment) traitModels := []*traits.TraitModel{ - {TraitKey: fixtures.SegmentConditionProperty, TraitValue: fixtures.SegmentConditionStringValaue}, + {TraitKey: fixtures.SegmentConditionProperty, TraitValue: fixtures.SegmentConditionStringValue}, } allFeatureStates := flagengine.GetIdentityFeatureStates(envWithSegmentOverride, identity, traitModels...) diff --git a/flagengine/utils/fixtures/fixtures.go b/flagengine/utils/fixtures/fixtures.go index ff87aaa..bf5b8a7 100644 --- a/flagengine/utils/fixtures/fixtures.go +++ b/flagengine/utils/fixtures/fixtures.go @@ -14,15 +14,15 @@ import ( ) const ( - SegmentConditionProperty = "foo" - SegmentConditionStringValaue = "bar" + SegmentConditionProperty = "foo" + SegmentConditionStringValue = "bar" ) func SegmentCondition() *segments.SegmentConditionModel { return &segments.SegmentConditionModel{ Operator: segments.Equal, Property: SegmentConditionProperty, - Value: SegmentConditionStringValaue, + Value: SegmentConditionStringValue, } } From 81b17be7910618954018f9bfdbae1286643ac4dd Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 9 Aug 2024 23:42:06 +0100 Subject: [PATCH 7/9] avoid mutation --- flagengine/engine_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/flagengine/engine_test.go b/flagengine/engine_test.go index e8b3697..e6e2a33 100644 --- a/flagengine/engine_test.go +++ b/flagengine/engine_test.go @@ -101,8 +101,6 @@ func TestIdentityGetAllFeatureStatesSegmentsOnly(t *testing.T) { } func TestIdentityGetAllFeatureStatesWithTraits(t *testing.T) { - // t.Parallel() - feature1, _, segment, env, identity := fixtures.GetFixtures() envWithSegmentOverride := fixtures.EnvironmentWithSegmentOverride(env, fixtures.SegmentOverrideFs(segment, feature1), segment) From f5e3710af0259a4c805bcbaa32c6e161848eb6d5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 7 Oct 2024 18:04:58 +0100 Subject: [PATCH 8/9] add nice context interfaces --- client.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/client.go b/client.go index 2064b1f..49485e2 100644 --- a/client.go +++ b/client.go @@ -39,6 +39,17 @@ type Client struct { errorHandler func(handler *FlagsmithAPIError) } +// Returns context with provided EvaluationContext instance set. +func WithEvaluationContext(ctx context.Context, ec EvaluationContext) context.Context { + return context.WithValue(ctx, contextKeyEvaluationContext, ec) +} + +// Retrieve EvaluationContext instance from context. +func GetEvaluationContextFromCtx(ctx context.Context) (ec EvaluationContext, ok bool) { + ec, ok = ctx.Value(contextKeyEvaluationContext).(EvaluationContext) + return ec, ok +} + // NewClient creates instance of Client with given configuration. func NewClient(apiKey string, options ...Option) *Client { c := &Client{ @@ -104,7 +115,7 @@ func NewClient(apiKey string, options ...Option) *Client { // * `EvaluationContext.Feature` is not yet supported. func (c *Client) GetFlags(ctx context.Context, ec *EvaluationContext) (f Flags, err error) { if ec != nil { - ctx = context.WithValue(ctx, contextKeyEvaluationContext, ec) + ctx = WithEvaluationContext(ctx, *ec) if ec.Identity != nil { return c.GetIdentityFlags(ctx, ec.Identity.Identifier, mapIdentityEvaluationContextToTraits(*ec.Identity)) } @@ -212,9 +223,9 @@ func (c *Client) BulkIdentify(ctx context.Context, batch []*IdentityTraits) erro // Will return an error in case of failure or unexpected response. func (c *Client) GetEnvironmentFlagsFromAPI(ctx context.Context) (Flags, error) { req := c.client.NewRequest() - maybeEc := ctx.Value(contextKeyEvaluationContext) - if maybeEc != nil { - envCtx := maybeEc.(*EvaluationContext).Environment + ec, ok := GetEvaluationContextFromCtx(ctx) + if ok { + envCtx := ec.Environment if envCtx != nil { req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey) } @@ -243,9 +254,8 @@ func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, Transient *bool `json:"transient,omitempty"` }{Identifier: identifier, Traits: traits} req := c.client.NewRequest() - maybeEc := ctx.Value(contextKeyEvaluationContext) - if maybeEc != nil { - ec := maybeEc.(*EvaluationContext) + ec, ok := GetEvaluationContextFromCtx(ctx) + if ok { envCtx := ec.Environment if envCtx != nil { req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey) From 511d1e565671a4792d6d0d7ff095c3a664ac8cfa Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 7 Oct 2024 18:05:50 +0100 Subject: [PATCH 9/9] use merged schema --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1ea9c02..2050b8b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .EXPORT_ALL_VARIABLES: -EVALUATION_CONTEXT_SCHEMA_URL ?= https://raw.githubusercontent.com/Flagsmith/flagsmith/feat/evaluation-context-schema/sdk/evaluation-context.json +EVALUATION_CONTEXT_SCHEMA_URL ?= https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-context.json .PHONY: generate-evaluation-context