diff --git a/.golangci.yml b/.golangci.yml index ea432d19..ff6c7d77 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 - diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2050b8b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.EXPORT_ALL_VARIABLES: + +EVALUATION_CONTEXT_SCHEMA_URL ?= https://raw.githubusercontent.com/Flagsmith/flagsmith/main/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 53aefeb5..49485e24 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 @@ -34,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{ @@ -43,8 +59,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() @@ -86,9 +102,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 = WithEvaluationContext(ctx, *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 { @@ -117,6 +158,8 @@ 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. +// +// 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 { @@ -179,7 +222,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() + ec, ok := GetEvaluationContextFromCtx(ctx) + if ok { + envCtx := ec.Environment + if envCtx != nil { + req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey) + } + } + resp, err := req. SetContext(ctx). ForceContentType("application/json"). Get(c.config.baseURL + "flags/") @@ -200,8 +251,22 @@ func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, body := struct { Identifier string `json:"identifier"` Traits []*Trait `json:"traits,omitempty"` + Transient *bool `json:"transient,omitempty"` }{Identifier: identifier, Traits: traits} - resp, err := c.client.NewRequest(). + req := c.client.NewRequest() + ec, ok := GetEvaluationContextFromCtx(ctx) + if ok { + 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 := req. SetBody(&body). SetContext(ctx). ForceContentType("application/json"). @@ -302,7 +367,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 cb732ad2..e8a91631 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() diff --git a/const.go b/const.go new file mode 100644 index 00000000..2d3d26e7 --- /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 00000000..0ce77761 --- /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,omitempty"` + Traits map[string]*TraitEvaluationContext `json:"traits,omitempty"` + 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 00000000..ec1f356f --- /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/flagengine/engine_test.go b/flagengine/engine_test.go index 7e271a21..e6e2a33d 100644 --- a/flagengine/engine_test.go +++ b/flagengine/engine_test.go @@ -101,14 +101,12 @@ 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) 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 ff87aaa1..bf5b8a71 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, } } diff --git a/models.go b/models.go index a84250d3..490e5477 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 { diff --git a/utils.go b/utils.go new file mode 100644 index 00000000..3408a56b --- /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 +}