Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Support transient identities and traits #133

Merged
merged 9 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
run:
timeout: 3m
modules-download-mode: readonly
skip-dirs:

issues:
exclude-dirs:
- sample

linters:
Expand All @@ -13,4 +15,3 @@ linters:
- goimports
- misspell
- whitespace

8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.EXPORT_ALL_VARIABLES:

EVALUATION_CONTEXT_SCHEMA_URL ?= https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-context.json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly existential question, but should this point at main or a tag?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema is designed to be forward-compatible so main is pretty safe. That being said, we don't have any automation around it in CI/CD for Golang SDK yet.



.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
77 changes: 71 additions & 6 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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/")
Expand All @@ -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").
Expand Down Expand Up @@ -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()
}
Expand Down
150 changes: 150 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions const.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package flagsmith

const EnvironmentKeyHeader = "X-Environment-Key"
26 changes: 26 additions & 0 deletions evaluationcontext.go
Original file line number Diff line number Diff line change
@@ -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"`
}
33 changes: 33 additions & 0 deletions evaluationcontext_static.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading