From 2d4d2a178da14dde4ec13a33548057133760247b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 24 Jul 2024 16:47:21 +0100 Subject: [PATCH 1/7] 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 230b77d..6646237 100644 --- a/client.go +++ b/client.go @@ -108,6 +108,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. // @@ -118,13 +122,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 } } @@ -191,11 +195,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 c5ea887..f61e84b 100644 --- a/client_test.go +++ b/client_test.go @@ -231,7 +231,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) @@ -257,7 +257,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) @@ -272,55 +272,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) { @@ -608,7 +631,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() @@ -650,7 +673,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 dc0b89c8debd94d65332cf6dc3259f1c3aa976e7 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 24 Jul 2024 16:58:17 +0100 Subject: [PATCH 2/7] 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 7b73f0840f4f3153ec3781841bd182887b4784b0 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 24 Jul 2024 17:00:46 +0100 Subject: [PATCH 3/7] linting --- client_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/client_test.go b/client_test.go index f61e84b..5f75e0b 100644 --- a/client_test.go +++ b/client_test.go @@ -342,7 +342,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 74357040a04770338c79f9f22a0aaecfc9fab711 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 2 Aug 2024 17:56:26 +0100 Subject: [PATCH 4/7] Add evaluation context support --- Makefile | 8 ++ client.go | 78 ++++++++--- client_test.go | 254 ++++++++++++++++++++++++++--------- evaluationcontext.go | 26 ++++ evaluationcontext_static.go | 33 +++++ internal/flaghttp/client.go | 3 + internal/flaghttp/request.go | 12 ++ utils.go | 31 +++++ 8 files changed, 366 insertions(+), 79 deletions(-) create mode 100644 Makefile 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 6646237..6cc1be4 100644 --- a/client.go +++ b/client.go @@ -14,9 +14,13 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/segments" "github.com/Flagsmith/flagsmith-go-client/v3/internal/flaghttp" - . "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/identities/traits" + 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 @@ -44,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", + flaghttp.EnvironmentKeyHeader: c.apiKey, }) c.client.SetTimeout(c.config.timeout) c.log = createLogger() @@ -87,9 +91,34 @@ func NewClient(apiKey string, options ...Option) *Client { // 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 { @@ -108,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. // @@ -122,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 } } @@ -180,7 +207,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(flaghttp.EnvironmentKeyHeader, envCtx.APIKey) + } + } + resp, err := req. SetContext(ctx). ForceContentType("application/json"). Get(c.config.baseURL + "flags/") @@ -195,16 +230,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(flaghttp.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"). @@ -295,7 +341,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 5f75e0b..6a1ebb1 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() { @@ -144,6 +165,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() @@ -231,7 +381,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) @@ -257,7 +407,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) @@ -272,77 +422,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) { @@ -630,7 +758,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() @@ -672,7 +800,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/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/internal/flaghttp/client.go b/internal/flaghttp/client.go index ee30ef8..f823118 100644 --- a/internal/flaghttp/client.go +++ b/internal/flaghttp/client.go @@ -7,6 +7,8 @@ import ( "time" ) +const EnvironmentKeyHeader = "X-Environment-Key" + type Logger interface { Errorf(format string, v ...any) Warnf(format string, v ...any) @@ -58,6 +60,7 @@ func (c *client) NewRequest() Request { func (c *client) R() Request { return &request{ client: c, + header: http.Header{}, } } diff --git a/internal/flaghttp/request.go b/internal/flaghttp/request.go index 16b1be4..96286e9 100644 --- a/internal/flaghttp/request.go +++ b/internal/flaghttp/request.go @@ -17,12 +17,14 @@ type Request interface { Post(url string) (Response, error) SetBody(body any) Request SetContext(ctx context.Context) Request + SetHeader(header, value string) Request SetResult(res any) Request SetError(err any) Request } type request struct { client *client + header http.Header body any ctx context.Context err any @@ -69,6 +71,12 @@ func (r *request) SetError(err any) Request { return r } +func (r *request) SetHeader(header, value string) Request { + r.header.Set(header, value) + + return r +} + func (r *request) do(method, url string) (Response, error) { var ( transport = r.client.transport.Clone() @@ -115,6 +123,10 @@ func (r *request) do(method, url string) (Response, error) { req.Header[k] = v } + for k, v := range r.header { + req.Header[k] = v + } + if r.body != nil { req.Header.Set("Content-Type", "application/json") } 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 208484cfdc2667dedb70e0a2c15406a970e87ac6 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 9 Aug 2024 17:20:40 +0100 Subject: [PATCH 5/7] 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 8ceb6134210ec3e8d87d82cece2cbe9fc01d05e3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 9 Aug 2024 23:36:55 +0100 Subject: [PATCH 6/7] 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 d91e2a4180004fd2aaaab2af27f20aba789d716b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 9 Aug 2024 23:42:06 +0100 Subject: [PATCH 7/7] 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)