diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fc66ea2a..ba099591 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -30,9 +30,6 @@ jobs: with: submodules: recursive - - name: Build evaluation context struct - run: make generate-evaluation-context - - name: Get dependencies run: | go get -v -t -d ./... diff --git a/Makefile b/Makefile deleted file mode 100644 index 62aea784..00000000 --- a/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -.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/analytics.go b/analytics.go index 8cb0d14b..c2f5e3bf 100644 --- a/analytics.go +++ b/analytics.go @@ -3,6 +3,8 @@ package flagsmith import ( "context" "fmt" + "log/slog" + "net/http" "sync" "time" @@ -20,10 +22,10 @@ type AnalyticsProcessor struct { client *resty.Client store *analyticDataStore endpoint string - log Logger + log *slog.Logger } -func NewAnalyticsProcessor(ctx context.Context, client *resty.Client, baseURL string, timerInMilli *int, log Logger) *AnalyticsProcessor { +func NewAnalyticsProcessor(ctx context.Context, client *resty.Client, baseURL string, timerInMilli *int, log *slog.Logger) *AnalyticsProcessor { data := make(map[string]int) dataStore := analyticDataStore{data: data} tickerInterval := AnalyticsTimerInMilli @@ -45,8 +47,12 @@ func (a *AnalyticsProcessor) start(ctx context.Context, tickerInterval int) { for { select { case <-ticker.C: - if err := a.Flush(ctx); err != nil { - a.log.Warnf("Failed to send analytics data: %s", err) + if resp, err := a.Flush(ctx); err != nil { + a.log.Warn("failed to send analytics data", + "error", err, + slog.Int("status", resp.StatusCode), + slog.String("url", resp.Request.URL.String()), + ) } case <-ctx.Done(): return @@ -54,23 +60,21 @@ func (a *AnalyticsProcessor) start(ctx context.Context, tickerInterval int) { } } -func (a *AnalyticsProcessor) Flush(ctx context.Context) error { +func (a *AnalyticsProcessor) Flush(ctx context.Context) (*http.Response, error) { a.store.mu.Lock() defer a.store.mu.Unlock() if len(a.store.data) == 0 { - return nil + return nil, nil } resp, err := a.client.R().SetContext(ctx).SetBody(a.store.data).Post(a.endpoint) if err != nil { - return err + return nil, err } if !resp.IsSuccess() { - return fmt.Errorf("received unexpected response from server: %s", resp.Status()) + return resp.RawResponse, fmt.Errorf("AnalyticsProcessor.Flush received error response %d %s", resp.StatusCode(), resp.Status()) } - - // Clear the cache in case of success. a.store.data = make(map[string]int) - return nil + return resp.RawResponse, nil } func (a *AnalyticsProcessor) TrackFeature(featureName string) { diff --git a/analytics_test.go b/analytics_test.go index 3bffef28..e3b78c8e 100644 --- a/analytics_test.go +++ b/analytics_test.go @@ -3,6 +3,7 @@ package flagsmith import ( "context" "io" + "log/slog" "net/http" "net/http/httptest" "sync" @@ -14,7 +15,6 @@ import ( "github.com/go-resty/resty/v2" ) -const BaseURL = "http://localhost:8000/api/v1/" const EnvironmentAPIKey = "test_key" func TestAnalytics(t *testing.T) { @@ -43,7 +43,7 @@ func TestAnalytics(t *testing.T) { client.SetHeader("X-Environment-Key", EnvironmentAPIKey) // Now let's create the processor - processor := NewAnalyticsProcessor(context.Background(), client, server.URL+"/api/v1/", &analyticsTimer, createLogger()) + processor := NewAnalyticsProcessor(context.Background(), client, server.URL+"/api/v1/", &analyticsTimer, slog.Default()) // and, track some features processor.TrackFeature("feature_1") diff --git a/client.go b/client.go index ac3e4f6f..80cb19cd 100644 --- a/client.go +++ b/client.go @@ -2,9 +2,10 @@ package flagsmith import ( "context" + "errors" "fmt" + "log/slog" "strings" - "sync/atomic" "time" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" @@ -16,185 +17,189 @@ import ( enginetraits "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" ) -type contextKey string - -var contextKeyEvaluationContext = contextKey("evaluationContext") - -// Client provides various methods to query Flagsmith API. +// Client is a Flagsmith client, used to evaluate feature flags. Use [NewClient] to instantiate a client. +// +// Use [Client.GetFlags] to evaluate feature flags. type Client struct { - apiKey string - config config - - environment atomic.Value - identitiesWithOverrides atomic.Value - - analyticsProcessor *AnalyticsProcessor + baseURL string + timeout time.Duration + proxy string + localEvaluation bool + envRefreshInterval time.Duration + realtimeBaseUrl string + useRealtime bool defaultFlagHandler func(string) (Flag, error) + errorHandler func(handler *APIError) + ctxLocalEval context.Context + ctxAnalytics context.Context - client *resty.Client - ctxLocalEval context.Context - ctxAnalytics context.Context - log Logger - offlineHandler OfflineHandler - 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) -} + state environmentState -// Retrieve EvaluationContext instance from context. -func GetEvaluationContextFromCtx(ctx context.Context) (ec EvaluationContext, ok bool) { - ec, ok = ctx.Value(contextKeyEvaluationContext).(EvaluationContext) - return ec, ok + analyticsProcessor *AnalyticsProcessor + log *slog.Logger + client *resty.Client } -// NewClient creates instance of Client with given configuration. -func NewClient(apiKey string, options ...Option) *Client { +// NewClient creates a Flagsmith [Client] using the environment determined by apiKey. +// +// Feature flags are evaluated remotely by the Flagsmith API over HTTP by default. +// To evaluate flags locally, use [WithLocalEvaluation] and a server-side SDK key. +// +// Example: +// +// func GetDefaultFlag(key string) (Flag, error) { +// return Flag{ +// FeatureName: key, +// IsDefault: true, +// Value: `{"colour": "#FFFF00"}`, +// Enabled: true, +// }, nil +// } +// flagsmithClient := flagsmith.NewClient( +// os.Getenv("FLAGSMITH_SDK_KEY"), +// flagsmith.WithLocalEvaluation(context.Background()), +// flagsmith.WithDefaultHandler(GetDefaultFlag), +// ) +func NewClient(apiKey string, options ...Option) (*Client, error) { + // Defaults c := &Client{ - apiKey: apiKey, - config: defaultConfig(), - client: resty.New(), - } - - c.client.SetHeaders(map[string]string{ - "Accept": "application/json", - EnvironmentKeyHeader: c.apiKey, - }) - c.client.SetTimeout(c.config.timeout) - c.log = createLogger() - + log: slog.Default(), + baseURL: DefaultBaseURL, + timeout: DefaultTimeout, + envRefreshInterval: time.Second * 60, + realtimeBaseUrl: DefaultRealtimeBaseUrl, + client: resty.New(), + } + // Apply options for _, opt := range options { if opt != nil { opt(c) } } - c.client.SetLogger(c.log) - if c.config.offlineMode && c.offlineHandler == nil { - panic("offline handler must be provided to use offline mode.") + if c.state.IsOffline() { + return c, nil } - if c.defaultFlagHandler != nil && c.offlineHandler != nil { - panic("default flag handler and offline handler cannot be used together.") + if c.defaultFlagHandler == nil && apiKey == "" { + return nil, errors.New("no API key, offline environment or default flag handler was provided") } - if c.config.localEvaluation && c.offlineHandler != nil { - panic("local evaluation and offline handler cannot be used together.") - } - if c.offlineHandler != nil { - c.environment.Store(c.offlineHandler.GetEnvironment()) + + c.client = c.client. + SetTimeout(c.timeout). + OnBeforeRequest(newRestyLogRequestMiddleware(c.log)). + OnAfterResponse(newRestyLogResponseMiddleware(c.log)). + SetLogger(restySlogLogger{c.log}). + SetHeaders(map[string]string{ + "Accept": "application/json", + EnvironmentKeyHeader: apiKey, + }) + if c.proxy != "" { + c.client = c.client.SetProxy(c.proxy) } - if c.config.localEvaluation { + if c.localEvaluation { if !strings.HasPrefix(apiKey, "ser.") { - panic("In order to use local evaluation, please generate a server key in the environment settings page.") + return nil, errors.New("using local flag evaluation requires a server-side SDK key; got " + apiKey) } - if c.config.useRealtime { + if c.useRealtime { go c.startRealtimeUpdates(c.ctxLocalEval) } else { go c.pollEnvironment(c.ctxLocalEval) } } + // Initialise analytics processor - if c.config.enableAnalytics { - c.analyticsProcessor = NewAnalyticsProcessor(c.ctxAnalytics, c.client, c.config.baseURL, nil, c.log) + if c.ctxAnalytics != nil { + c.analyticsProcessor = NewAnalyticsProcessor(c.ctxAnalytics, c.client, c.baseURL, nil, c.log) } - return c + + return c, nil } -// 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)) - } +// MustNewClient panics if NewClient returns an error. +func MustNewClient(apiKey string, options ...Option) *Client { + client, err := NewClient(apiKey, options...) + if err != nil { + panic(err) } - return c.GetEnvironmentFlags(ctx) + return client } -// Returns `Flags` struct holding all the flags for the current environment. +// GetFlags evaluates the feature flags within an [EvaluationContext]. // -// 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. +// When flag evaluation fails, the value of each Flag is determined by the default flag handler +// from [WithDefaultHandler], if one was provided. // -// 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 { - return f, nil - } +// Flags are evaluated remotely by the Flagsmith API by default. +// To evaluate flags locally, instantiate a client using [WithLocalEvaluation]. +// +// The following example shows how to evaluate flags for an identity with some application-defined traits: +// +// evalCtx := flagsmith.NewEvaluationContext( +// "my-user-123", +// map[string]interface{}{ +// "company": "ACME Corporation", +// }, +// ) +// userFlags, _ := GetFlags(ctx.Background(), evalCtx) +// if userFlags.IsFeatureEnabled("my_feature_key") { +// // Flag is enabled for this evaluation context +// } +func (c *Client) GetFlags(ctx context.Context, ec EvaluationContext) (f Flags, err error) { + if ec.identifier == "" { + f, err = c.GetEnvironmentFlags(ctx) } else { - if f, err = c.GetEnvironmentFlagsFromAPI(ctx); err == nil { - return f, nil - } + f, err = c.GetIdentityFlags(ctx, ec.identifier, ec.traits) } - if c.offlineHandler != nil { - return c.getEnvironmentFlagsFromEnvironment() + if err == nil { + return f, nil } else if c.defaultFlagHandler != nil { return Flags{defaultFlagHandler: c.defaultFlagHandler}, nil + } else { + return Flags{}, errors.New("GetFlags failed and no default flag handler was provided") } - return Flags{}, &FlagsmithClientError{msg: fmt.Sprintf("Failed to fetch flags with error: %s", err)} } -// Returns `Flags` struct holding all the flags for the current environment for -// a given identity. -// -// If local evaluation is disabled it will also upsert all traits to the -// Flagsmith API for future evaluations. Providing a trait with a value of nil -// will remove the trait from the identity if it exists. -// -// 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 { - return f, nil - } - } else { - if f, err = c.GetIdentityFlagsFromAPI(ctx, identifier, traits); err == nil { - return f, nil - } +// UpdateEnvironment fetches the current environment state from the Flagsmith API. It is called periodically when using +// [WithLocalEvaluation], or when [WithRealtime] is enabled and an update event was received. +func (c *Client) UpdateEnvironment(ctx context.Context) error { + var env environments.EnvironmentModel + resp, err := c.client. + NewRequest(). + SetContext(ctx). + SetResult(&env). + ForceContentType("application/json"). + Get(c.baseURL + "environment-document/") + + if err != nil { + return c.handleError(&APIError{Err: err}) } - if c.offlineHandler != nil { - return c.getIdentityFlagsFromEnvironment(identifier, traits) - } else if c.defaultFlagHandler != nil { - return Flags{defaultFlagHandler: c.defaultFlagHandler}, nil + if !resp.IsSuccess() { + e := &APIError{response: resp.RawResponse} + return c.handleError(e) } - return Flags{}, &FlagsmithClientError{msg: fmt.Sprintf("Failed to fetch flags with error: %s", err)} + c.state.SetEnvironment(&env) + + c.log.Info("environment updated", "environment", env.APIKey) + return nil } -// Returns an array of segments that the given identity is part of. -func (c *Client) GetIdentitySegments(identifier string, traits []*Trait) ([]*segments.SegmentModel, error) { - if env, ok := c.environment.Load().(*environments.EnvironmentModel); ok { - identity := c.getIdentityModel(identifier, env.APIKey, traits) - return flagengine.GetIdentitySegments(env, &identity), nil +// GetIdentitySegments returns the segments that this evaluation context is a part of. It requires a local environment +// provided by [WithLocalEvaluation] and/or [WithOfflineEnvironment]. +func (c *Client) GetIdentitySegments(ec EvaluationContext) (s []*segments.SegmentModel, err error) { + env := c.state.GetEnvironment() + if env == nil { + return s, errors.New("GetIdentitySegments called with no local environment available") } - return nil, &FlagsmithClientError{msg: "flagsmith: Local evaluation required to obtain identity segments"} + identity := c.getIdentityModel(ec.identifier, env.APIKey, ec.traits) + return flagengine.GetIdentitySegments(env, identity), nil } // BulkIdentify can be used to create/overwrite identities(with traits) in bulk // NOTE: This method only works with Edge API endpoint. func (c *Client) BulkIdentify(ctx context.Context, batch []*IdentityTraits) error { if len(batch) > BulkIdentifyMaxCount { - msg := fmt.Sprintf("flagsmith: batch size must be less than %d", BulkIdentifyMaxCount) - return &FlagsmithAPIError{Msg: msg} + return fmt.Errorf("batch size must be less than %d", BulkIdentifyMaxCount) } body := struct { @@ -205,92 +210,100 @@ func (c *Client) BulkIdentify(ctx context.Context, batch []*IdentityTraits) erro SetBody(&body). SetContext(ctx). ForceContentType("application/json"). - Post(c.config.baseURL + "bulk-identities/") - if resp.StatusCode() == 404 { - msg := "flagsmith: Bulk identify endpoint not found; Please make sure you are using Edge API endpoint" - return &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} - } + Post(c.baseURL + "bulk-identities/") + if err != nil { - msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err) - return &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + return err } - if !resp.IsSuccess() { - msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status()) - return &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + if resp.IsError() { + return fmt.Errorf("BulkIdentify received response with status %d %s", resp.StatusCode(), resp.Status()) } return nil } -// 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) { - req := c.client.NewRequest() - ec, ok := GetEvaluationContextFromCtx(ctx) - if ok { - envCtx := ec.Environment - if envCtx != nil { - req.SetHeader(EnvironmentKeyHeader, envCtx.APIKey) - } +// GetEnvironmentFlags calls GetFlags for the default EvaluationContext. +func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { + if c.state.IsOffline() || c.localEvaluation { + f, err = c.getEnvironmentFlagsFromEnvironment() + } else { + f, err = c.getEnvironmentFlagsFromAPI(ctx) + } + return f, err +} + +// GetIdentityFlags calls GetFlags using this identifier and traits as the EvaluationContext. +func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits map[string]interface{}) (f Flags, err error) { + if c.state.IsOffline() || c.localEvaluation { + f, err = c.getIdentityFlagsFromEnvironment(identifier, traits) + } else { + f, err = c.getIdentityFlagsFromAPI(ctx, identifier, traits) } + return f, err +} + +// getEnvironmentFlagsFromAPI tries to contact the Flagsmith API to get the latest environment data. +func (c *Client) getEnvironmentFlagsFromAPI(ctx context.Context) (Flags, error) { + req := c.client.NewRequest() resp, err := req. SetContext(ctx). ForceContentType("application/json"). - Get(c.config.baseURL + "flags/") + Get(c.baseURL + "flags/") if err != nil { - msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err) - return Flags{}, &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + return Flags{}, &APIError{Err: err} } if !resp.IsSuccess() { - msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status()) - return Flags{}, &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + return Flags{}, &APIError{response: resp.RawResponse} } return makeFlagsFromAPIFlags(resp.Body(), c.analyticsProcessor, c.defaultFlagHandler) } -// GetIdentityFlagsFromAPI tries to contact the Flagsmith API to get the latest identity flags. +// 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 map[string]interface{}) (Flags, error) { + tt := make([]Trait, 0, len(traits)) + for k, v := range traits { + tt = append(tt, Trait{Key: k, Value: v}) + } + body := struct { - Identifier string `json:"identifier"` - Traits []*Trait `json:"traits,omitempty"` - Transient *bool `json:"transient,omitempty"` - }{Identifier: identifier, Traits: traits} + Identifier string `json:"identifier"` + Traits []Trait `json:"traits,omitempty"` + }{Identifier: identifier, Traits: tt} 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"). - Post(c.config.baseURL + "identities/") + Post(c.baseURL + "identities/") if err != nil { - msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err) - return Flags{}, &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + return Flags{}, &APIError{Err: err} } if !resp.IsSuccess() { - msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status()) - return Flags{}, &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} + return Flags{}, &APIError{response: resp.RawResponse} } return makeFlagsfromIdentityAPIJson(resp.Body(), c.analyticsProcessor, c.defaultFlagHandler) } -func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits []*Trait) (Flags, error) { - env, ok := c.environment.Load().(*environments.EnvironmentModel) - if !ok { - return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") +func (c *Client) getEnvironmentFlagsFromEnvironment() (Flags, error) { + env := c.state.GetEnvironment() + if env == nil { + return Flags{}, fmt.Errorf("getEnvironmentFlagsFromEnvironment: no local environment is available") + } + return makeFlagsFromFeatureStates( + env.FeatureStates, + c.analyticsProcessor, + c.defaultFlagHandler, + "", + ), nil +} + +func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits map[string]interface{}) (Flags, error) { + env := c.state.GetEnvironment() + if env == nil { + return Flags{}, fmt.Errorf("getIdentityFlagsFromDocument: no local environment is available") } identity := c.getIdentityModel(identifier, env.APIKey, traits) - featureStates := flagengine.GetIdentityFeatureStates(env, &identity) + featureStates := flagengine.GetIdentityFeatureStates(env, identity) flags := makeFlagsFromFeatureStates( featureStates, c.analyticsProcessor, @@ -300,89 +313,49 @@ func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits []*Tr return flags, nil } -func (c *Client) getEnvironmentFlagsFromEnvironment() (Flags, error) { - env, ok := c.environment.Load().(*environments.EnvironmentModel) - if !ok { - return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") - } - return makeFlagsFromFeatureStates( - env.FeatureStates, - c.analyticsProcessor, - c.defaultFlagHandler, - "", - ), nil -} - func (c *Client) pollEnvironment(ctx context.Context) { update := func() { - ctx, cancel := context.WithTimeout(ctx, c.config.envRefreshInterval) + ctx, cancel := context.WithTimeout(ctx, c.envRefreshInterval) defer cancel() err := c.UpdateEnvironment(ctx) if err != nil { - c.log.Errorf("Failed to update environment: %v", err) + c.log.Error("pollEnvironment failed", "error", err) } } update() - ticker := time.NewTicker(c.config.envRefreshInterval) + ticker := time.NewTicker(c.envRefreshInterval) for { select { case <-ticker.C: + c.log.Debug("polling environment") update() case <-ctx.Done(): return } } } -func (c *Client) UpdateEnvironment(ctx context.Context) error { - var env environments.EnvironmentModel - resp, err := c.client.NewRequest(). - SetContext(ctx). - SetResult(&env). - ForceContentType("application/json"). - Get(c.config.baseURL + "environment-document/") - if err != nil { - msg := fmt.Sprintf("flagsmith: error performing request to Flagsmith API: %s", err) - f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} - if c.errorHandler != nil { - c.errorHandler(f) - } - return f - } - if resp.StatusCode() != 200 { - msg := fmt.Sprintf("flagsmith: unexpected response from Flagsmith API: %s", resp.Status()) - f := &FlagsmithAPIError{Msg: msg, Err: err, ResponseStatusCode: resp.StatusCode(), ResponseStatus: resp.Status()} - if c.errorHandler != nil { - c.errorHandler(f) - } - return f +func (c *Client) getIdentityModel(identifier string, apiKey string, traits map[string]interface{}) *identities.IdentityModel { + identityTraits := make([]*enginetraits.TraitModel, 0, len(traits)) + for k, v := range traits { + identityTraits = append(identityTraits, enginetraits.NewTrait(k, v)) } - c.environment.Store(&env) - identitiesWithOverrides := make(map[string]identities.IdentityModel) - for _, id := range env.IdentityOverrides { - identitiesWithOverrides[id.Identifier] = *id - } - c.identitiesWithOverrides.Store(identitiesWithOverrides) - return nil -} - -func (c *Client) getIdentityModel(identifier string, apiKey string, traits []*Trait) identities.IdentityModel { - identityTraits := make([]*enginetraits.TraitModel, len(traits)) - for i, trait := range traits { - identityTraits[i] = trait.ToTraitModel() - } - - identitiesWithOverrides, _ := c.identitiesWithOverrides.Load().(map[string]identities.IdentityModel) - identity, ok := identitiesWithOverrides[identifier] - if ok { + if identity := c.state.GetIdentityOverride(identifier); identity != nil { identity.IdentityTraits = identityTraits return identity } - return identities.IdentityModel{ + return &identities.IdentityModel{ Identifier: identifier, IdentityTraits: identityTraits, EnvironmentAPIKey: apiKey, } } + +func (c *Client) handleError(err *APIError) *APIError { + if c.errorHandler != nil { + c.errorHandler(err) + } + return err +} diff --git a/client_test.go b/client_test.go index 4897aad7..177b461a 100644 --- a/client_test.go +++ b/client_test.go @@ -2,7 +2,7 @@ package flagsmith_test import ( "context" - "errors" + "encoding/json" "fmt" "io" "net/http" @@ -26,7 +26,25 @@ func getTestHttpServer(t *testing.T, expectedPath string, expectedEnvKey string, rawBody, err := io.ReadAll(req.Body) assert.NoError(t, err) - assert.Equal(t, *expectedRequestBody, string(rawBody)) + // Use JSON unmarshaling to compare structures instead of direct string comparison + var expectedJSON, actualJSON map[string]interface{} + err = json.Unmarshal([]byte(*expectedRequestBody), &expectedJSON) + assert.NoError(t, err) + + err = json.Unmarshal(rawBody, &actualJSON) + assert.NoError(t, err) + + assert.Equal(t, expectedJSON["identifier"], actualJSON["identifier"]) + + expectedTraits, expectedHasTraits := expectedJSON["traits"] + actualTraits, actualHasTraits := actualJSON["traits"] + + assert.Equal(t, expectedHasTraits, actualHasTraits) + + if expectedHasTraits && actualHasTraits { + // Compare traits if they exist + assert.Equal(t, expectedTraits, actualTraits) + } } rw.Header().Set("Content-Type", "application/json") @@ -40,70 +58,10 @@ func getTestHttpServer(t *testing.T, expectedPath string, expectedEnvKey string, func TestClientErrorsIfLocalEvaluationWithNonServerSideKey(t *testing.T) { // When, Then assert.Panics(t, func() { - _ = flagsmith.NewClient("key", flagsmith.WithLocalEvaluation(context.Background())) + _ = flagsmith.MustNewClient("key", flagsmith.WithLocalEvaluation(context.Background())) }) } -func TestClientErrorsIfOfflineModeWithoutOfflineHandler(t *testing.T) { - // When - defer func() { - if r := recover(); r != nil { - // Then - errMsg := fmt.Sprintf("%v", r) - expectedErrMsg := "offline handler must be provided to use offline mode." - assert.Equal(t, expectedErrMsg, errMsg, "Unexpected error message") - } - }() - - // Trigger panic - _ = flagsmith.NewClient("key", flagsmith.WithOfflineMode()) -} - -func TestClientErrorsIfDefaultHandlerAndOfflineHandlerAreBothSet(t *testing.T) { - // Given - envJsonPath := "./fixtures/environment.json" - offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) - assert.NoError(t, err) - - // When - defer func() { - if r := recover(); r != nil { - // Then - errMsg := fmt.Sprintf("%v", r) - expectedErrMsg := "default flag handler and offline handler cannot be used together." - assert.Equal(t, expectedErrMsg, errMsg, "Unexpected error message") - } - }() - - // Trigger panic - _ = flagsmith.NewClient("key", - flagsmith.WithOfflineHandler(offlineHandler), - flagsmith.WithDefaultHandler(func(featureName string) (flagsmith.Flag, error) { - return flagsmith.Flag{IsDefault: true}, nil - })) -} -func TestClientErrorsIfLocalEvaluationModeAndOfflineHandlerAreBothSet(t *testing.T) { - // Given - envJsonPath := "./fixtures/environment.json" - offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) - assert.NoError(t, err) - - // When - defer func() { - if r := recover(); r != nil { - // Then - errMsg := fmt.Sprintf("%v", r) - expectedErrMsg := "local evaluation and offline handler cannot be used together." - assert.Equal(t, expectedErrMsg, errMsg, "Unexpected error message") - } - }() - - // Trigger panic - _ = flagsmith.NewClient("key", - flagsmith.WithOfflineHandler(offlineHandler), - flagsmith.WithLocalEvaluation(context.Background())) -} - func TestClientUpdatesEnvironmentOnStartForLocalEvaluation(t *testing.T) { // Given ctx := context.Background() @@ -128,7 +86,7 @@ func TestClientUpdatesEnvironmentOnStartForLocalEvaluation(t *testing.T) { defer server.Close() // When - _ = flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + _ = flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithBaseURL(server.URL+"/api/v1/")) // Sleep to ensure that the server has time to update the environment @@ -164,7 +122,7 @@ func TestClientUpdatesEnvironmentOnEachRefresh(t *testing.T) { defer server.Close() // When - _ = flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + _ = flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithEnvironmentRefreshInterval(100*time.Millisecond), flagsmith.WithBaseURL(server.URL+"/api/v1/")) @@ -181,14 +139,13 @@ func TestClientUpdatesEnvironmentOnEachRefresh(t *testing.T) { 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/")) + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) - flags, err := client.GetFlags(ctx, nil) + flags, err := client.GetFlags(context.Background(), flagsmith.EvaluationContext{}) // Then assert.NoError(t, err) @@ -202,76 +159,7 @@ func TestGetFlags(t *testing.T) { assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) } -func TestGetFlagsTransientIdentity(t *testing.T) { - // Given - identifier := "transient" - transient := true - 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: &identifier, Transient: &transient}}) - - // 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 - identifier := "test_identity" - transient := true - 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}]}` - 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: &identifier, - Traits: map[string]*flagsmith.TraitEvaluationContext{ - "NullTrait": nil, - "StringTrait": {Value: "value"}, - "TransientTrait": { - Value: "value", - Transient: &transient, - }, - }, - }, - }) - - // 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) { +func TestGetEnvironmentFlags(t *testing.T) { // Given ctx := context.Background() expectedEnvKey := "different" @@ -279,35 +167,25 @@ func TestGetFlagsEnvironmentEvaluationContextFlags(t *testing.T) { defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + client := flagsmith.MustNewClient(expectedEnvKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) - _, err := client.GetFlags( - ctx, - &flagsmith.EvaluationContext{ - Environment: &flagsmith.EnvironmentEvaluationContext{APIKey: expectedEnvKey}, - }) - - // Then + _, err := client.GetEnvironmentFlags(ctx) assert.NoError(t, err) } func TestGetFlagsEnvironmentEvaluationContextIdentity(t *testing.T) { // Given - identifier := "test_identity" - 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/")) + client := flagsmith.MustNewClient(expectedEnvKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) _, err := client.GetFlags( - ctx, - &flagsmith.EvaluationContext{ - Environment: &flagsmith.EnvironmentEvaluationContext{APIKey: expectedEnvKey}, - Identity: &flagsmith.IdentityEvaluationContext{Identifier: &identifier}, - }) + context.Background(), + flagsmith.NewEvaluationContext("test_identity", map[string]interface{}{}), + ) // Then assert.NoError(t, err) @@ -320,7 +198,7 @@ func TestGetEnvironmentFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) { defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithBaseURL(server.URL+"/api/v1/")) err := client.UpdateEnvironment(ctx) @@ -356,9 +234,9 @@ func TestGetEnvironmentFlagsCallsAPIWhenLocalEnvironmentNotAvailable(t *testing. defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), flagsmith.WithDefaultHandler(func(featureName string) (flagsmith.Flag, error) { - return flagsmith.Flag{IsDefault: true}, nil + return flagsmith.Flag{}, nil })) flags, err := client.GetEnvironmentFlags(ctx) @@ -393,7 +271,7 @@ func TestGetIdentityFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler)) defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithBaseURL(server.URL+"/api/v1/")) err := client.UpdateEnvironment(ctx) @@ -419,7 +297,7 @@ func TestGetIdentityFlagsUseslocalOverridesWhenAvailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler)) defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithBaseURL(server.URL+"/api/v1/")) err := client.UpdateEnvironment(ctx) @@ -440,13 +318,15 @@ func TestGetIdentityFlagsUseslocalOverridesWhenAvailable(t *testing.T) { } func TestGetIdentityFlagsCallsAPIWhenLocalEnvironmentNotAvailableWithTraits(t *testing.T) { - // Given 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}]}` + + traits := map[string]interface{}{ + "stringTrait": "trait_value", + "intTrait": float64(1), + "floatTrait": 1.11, + "boolTrait": true, + "NoneTrait": nil, + } server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { assert.Equal(t, req.URL.Path, "/api/v1/identities/") @@ -455,38 +335,41 @@ func TestGetIdentityFlagsCallsAPIWhenLocalEnvironmentNotAvailableWithTraits(t *t // Test that we sent the correct body rawBody, err := io.ReadAll(req.Body) assert.NoError(t, err) - assert.Equal(t, expectedRequestBody, string(rawBody)) + + // Parse the actual JSON instead of comparing strings directly + var actualBody map[string]interface{} + err = json.Unmarshal(rawBody, &actualBody) + assert.NoError(t, err) + + assert.Equal(t, "test_identity", actualBody["identifier"]) + + // Check that all expected traits are present with correct values + traitsArray, _ := actualBody["traits"].([]interface{}) + assert.Equal(t, 5, len(traitsArray)) + + for _, trait := range traitsArray { + traitObj := trait.(map[string]interface{}) + k := traitObj["trait_key"].(string) + v := traitObj["trait_value"] + assert.Equal(t, traits[k], v) + } rw.Header().Set("Content-Type", "application/json") 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/")) - 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} - - traits := []*flagsmith.Trait{&stringTrait, &intTrait, &floatTrait, &boolTrait, &nillTrait} - // When - - flags, err := client.GetIdentityFlags(ctx, "test_identity", traits) + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, + flagsmith.WithBaseURL(server.URL+"/api/v1/")) - // Then + flags, err := client.GetFlags(ctx, flagsmith.NewEvaluationContext("test_identity", traits)) 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) @@ -509,9 +392,9 @@ func TestDefaultHandlerIsUsedWhenNoMatchingEnvironmentFlagReturned(t *testing.T) defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), flagsmith.WithDefaultHandler(func(featureName string) (flagsmith.Flag, error) { - return flagsmith.Flag{IsDefault: true}, nil + return flagsmith.Flag{}, nil })) flags, err := client.GetEnvironmentFlags(ctx) @@ -541,13 +424,13 @@ func TestDefaultHandlerIsUsedWhenTimeout(t *testing.T) { defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), flagsmith.WithRequestTimeout(10*time.Millisecond), flagsmith.WithDefaultHandler(func(featureName string) (flagsmith.Flag, error) { - return flagsmith.Flag{IsDefault: true}, nil + return flagsmith.Flag{}, nil })) - flags, err := client.GetEnvironmentFlags(ctx) + flags, err := client.GetFlags(ctx, flagsmith.EvaluationContext{}) // Then assert.NoError(t, err) @@ -564,12 +447,12 @@ func TestDefaultHandlerIsUsedWhenRequestFails(t *testing.T) { defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), flagsmith.WithDefaultHandler(func(featureName string) (flagsmith.Flag, error) { - return flagsmith.Flag{IsDefault: true}, nil + return flagsmith.Flag{}, nil })) - flags, err := client.GetEnvironmentFlags(ctx) + flags, err := client.GetFlags(ctx, flagsmith.EvaluationContext{}) // Then assert.NoError(t, err) @@ -586,12 +469,10 @@ func TestFlagsmithAPIErrorIsReturnedIfRequestFailsWithoutDefaultHandler(t *testi defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) - _, err := client.GetEnvironmentFlags(ctx) - assert.Error(t, err) - var flagErr *flagsmith.FlagsmithClientError - assert.True(t, errors.As(err, &flagErr)) + _, err := client.GetFlags(ctx, flagsmith.EvaluationContext{}) + assert.ErrorContains(t, err, "GetFlags failed and no default flag handler was provided") } func TestGetIdentitySegmentsNoTraits(t *testing.T) { @@ -600,13 +481,17 @@ func TestGetIdentitySegmentsNoTraits(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler)) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), - flagsmith.WithBaseURL(server.URL+"/api/v1/")) + client := flagsmith.MustNewClient( + fixtures.EnvironmentAPIKey, + flagsmith.WithLocalEvaluation(ctx), + flagsmith.WithBaseURL(server.URL+"/api/v1/"), + ) err := client.UpdateEnvironment(ctx) assert.NoError(t, err) - segments, err := client.GetIdentitySegments("test_identity", nil) + ec := flagsmith.NewEvaluationContext("test_identity", nil) + segments, err := client.GetIdentitySegments(ec) assert.NoError(t, err) assert.Equal(t, 0, len(segments)) @@ -618,22 +503,19 @@ func TestGetIdentitySegmentsWithTraits(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler)) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithBaseURL(server.URL+"/api/v1/")) err := client.UpdateEnvironment(ctx) assert.NoError(t, err) // lifted from fixtures/EnvironmentJson - trait_key := "foo" - trait_value := "bar" - - trait := flagsmith.Trait{TraitKey: trait_key, TraitValue: trait_value} - - traits := []*flagsmith.Trait{&trait} + ec := flagsmith.NewEvaluationContext("test_identity", map[string]interface{}{ + "foo": "bar", + }) // When - segments, err := client.GetIdentitySegments("test_identity", traits) + segments, err := client.GetIdentitySegments(ec) // Then assert.NoError(t, err) @@ -647,7 +529,7 @@ func TestBulkIdentifyReturnsErrorIfBatchSizeIsTooLargeToProcess(t *testing.T) { ctx := context.Background() traitKey := "foo" traitValue := "bar" - trait := flagsmith.Trait{TraitKey: traitKey, TraitValue: traitValue} + trait := flagsmith.Trait{Key: traitKey, Value: traitValue} data := []*flagsmith.IdentityTraits{} // A batch with more than 100 identities @@ -659,14 +541,14 @@ func TestBulkIdentifyReturnsErrorIfBatchSizeIsTooLargeToProcess(t *testing.T) { })) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) // When err := client.BulkIdentify(ctx, data) // Then assert.Error(t, err) - assert.Equal(t, "flagsmith: batch size must be less than 100", err.Error()) + assert.Equal(t, "batch size must be less than 100", err.Error()) } func TestBulkIdentifyReturnsErrorIfServerReturns404(t *testing.T) { @@ -678,14 +560,13 @@ func TestBulkIdentifyReturnsErrorIfServerReturns404(t *testing.T) { rw.WriteHeader(http.StatusNotFound) })) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) // When err := client.BulkIdentify(ctx, data) // Then assert.Error(t, err) - assert.Equal(t, "flagsmith: Bulk identify endpoint not found; Please make sure you are using Edge API endpoint", err.Error()) } func TestBulkIdentify(t *testing.T) { @@ -696,7 +577,7 @@ func TestBulkIdentify(t *testing.T) { identifierOne := "test_identity_1" identifierTwo := "test_identity_2" - trait := flagsmith.Trait{TraitKey: traitKey, TraitValue: traitValue} + trait := flagsmith.Trait{Key: traitKey, Value: traitValue} data := []*flagsmith.IdentityTraits{ {Traits: []*flagsmith.Trait{&trait}, Identifier: identifierOne}, {Traits: []*flagsmith.Trait{&trait}, Identifier: identifierTwo}, @@ -719,7 +600,7 @@ func TestBulkIdentify(t *testing.T) { })) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/")) // When err := client.BulkIdentify(ctx, data) @@ -734,7 +615,7 @@ func TestWithProxyClientOption(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(fixtures.EnvironmentDocumentHandler)) defer server.Close() - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithProxy(server.URL), + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithLocalEvaluation(ctx), flagsmith.WithProxy(server.URL), flagsmith.WithBaseURL("http://some-other-url-that-should-not-be-used/api/v1/")) err := client.UpdateEnvironment(ctx) @@ -759,52 +640,10 @@ func TestOfflineMode(t *testing.T) { ctx := context.Background() envJsonPath := "./fixtures/environment.json" - offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) + env, err := flagsmith.ReadEnvironmentFromFile(envJsonPath) assert.NoError(t, err) - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithOfflineMode(), flagsmith.WithOfflineHandler(offlineHandler)) - - // Then - flags, err := client.GetEnvironmentFlags(ctx) - 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) - - // And GetIdentityFlags works as well - flags, err = client.GetIdentityFlags(ctx, "test_identity", nil) - 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 TestOfflineHandlerIsUsedWhenRequestFails(t *testing.T) { - // Given - ctx := context.Background() - - envJsonPath := "./fixtures/environment.json" - offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) - assert.NoError(t, err) - - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithOfflineHandler(offlineHandler), - flagsmith.WithBaseURL(server.URL+"/api/v1/")) + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithOfflineEnvironment(env)) // Then flags, err := client.GetEnvironmentFlags(ctx) @@ -835,8 +674,7 @@ func TestPollErrorHandlerIsUsedWhenPollFails(t *testing.T) { // Given ctx := context.Background() var capturedError error - var statusCode int - var status string + var capturedResponse *http.Response server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusInternalServerError) @@ -844,12 +682,11 @@ func TestPollErrorHandlerIsUsedWhenPollFails(t *testing.T) { defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), - flagsmith.WithErrorHandler(func(handler *flagsmith.FlagsmithAPIError) { + flagsmith.WithErrorHandler(func(handler *flagsmith.APIError) { capturedError = handler.Err - statusCode = handler.ResponseStatusCode - status = handler.ResponseStatus + capturedResponse = handler.Response() }), ) @@ -858,8 +695,8 @@ func TestPollErrorHandlerIsUsedWhenPollFails(t *testing.T) { // Then assert.Equal(t, capturedError, nil) - assert.Equal(t, statusCode, 500) - assert.Equal(t, status, "500 Internal Server Error") + assert.Equal(t, capturedResponse.StatusCode, 500) + assert.Equal(t, capturedResponse.Status, "500 Internal Server Error") } func TestRealtime(t *testing.T) { @@ -913,7 +750,7 @@ func TestRealtime(t *testing.T) { defer server.Close() // When - client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, + client := flagsmith.MustNewClient(fixtures.EnvironmentAPIKey, flagsmith.WithBaseURL(server.URL+"/api/v1/"), flagsmith.WithLocalEvaluation(ctx), flagsmith.WithRealtime(), @@ -922,7 +759,7 @@ func TestRealtime(t *testing.T) { // Sleep to ensure that the server has time to update the environment time.Sleep(10 * time.Millisecond) - flags, err := client.GetFlags(ctx, nil) + flags, err := client.GetEnvironmentFlags(ctx) // Then assert.NoError(t, err) diff --git a/config.go b/config.go index ff0472ae..2b810eec 100644 --- a/config.go +++ b/config.go @@ -15,25 +15,3 @@ const ( BulkIdentifyMaxCount = 100 DefaultRealtimeBaseUrl = "https://realtime.flagsmith.com/" ) - -// config contains all configurable Client settings. -type config struct { - baseURL string - timeout time.Duration - localEvaluation bool - envRefreshInterval time.Duration - enableAnalytics bool - offlineMode bool - realtimeBaseUrl string - useRealtime bool -} - -// defaultConfig returns default configuration. -func defaultConfig() config { - return config{ - baseURL: DefaultBaseURL, - timeout: DefaultTimeout, - envRefreshInterval: time.Second * 60, - realtimeBaseUrl: DefaultRealtimeBaseUrl, - } -} diff --git a/errors.go b/errors.go index 794c0623..12e8a1e0 100644 --- a/errors.go +++ b/errors.go @@ -1,20 +1,18 @@ package flagsmith -type FlagsmithClientError struct { - msg string -} +import ( + "net/http" +) -type FlagsmithAPIError struct { - Msg string - Err error - ResponseStatusCode int - ResponseStatus string +type APIError struct { + Err error + response *http.Response } -func (e FlagsmithClientError) Error() string { - return e.msg +func (e APIError) Error() string { + return e.Err.Error() } -func (e FlagsmithAPIError) Error() string { - return e.Msg +func (e APIError) Response() *http.Response { + return e.response } diff --git a/evaluationcontext.go b/evaluationcontext.go index 0e303049..118c34ed 100644 --- a/evaluationcontext.go +++ b/evaluationcontext.go @@ -1,26 +1,25 @@ package flagsmith +// EvaluationContext is contextual data used during feature flag evaluation. +// +// The zero value represents the current Flagsmith environment. type EvaluationContext struct { - Environment *EnvironmentEvaluationContext `json:"environment,omitempty"` - Feature *FeatureEvaluationContext `json:"feature,omitempty"` - Identity *IdentityEvaluationContext `json:"identity,omitempty"` + identifier string + traits map[string]interface{} } -type EnvironmentEvaluationContext struct { - APIKey string `json:"api_key"` +// NewEvaluationContext creates a flag evaluation context for an identity. +func NewEvaluationContext(identifier string, traits map[string]interface{}) (ec EvaluationContext) { + ec.identifier = identifier + // Store a copy of the trait map + ec.traits = make(map[string]interface{}, len(traits)) + for k, v := range traits { + ec.traits[k] = v + } + return ec } -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"` +// NewTransientEvaluationContext is equivalent to NewEvaluationContext("", traits). +func NewTransientEvaluationContext(traits map[string]interface{}) (ec EvaluationContext) { + return NewEvaluationContext("", traits) } diff --git a/evaluationcontext_static.go b/evaluationcontext_static.go deleted file mode 100644 index fd00bfd6..00000000 --- a/evaluationcontext_static.go +++ /dev/null @@ -1,34 +0,0 @@ -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) - var transient = true - ec.Identity.Transient = &transient - return ec -} diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index f9877115..e43097ee 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit f987711516f088897f08b4fb8ffc06383e1ad547 +Subproject commit e43097ee273fa63dbb9589c02f89df3d10b51044 diff --git a/flagengine/engine_test.go b/flagengine/engine_test.go index 2f9c1f91..584c08a9 100644 --- a/flagengine/engine_test.go +++ b/flagengine/engine_test.go @@ -106,7 +106,7 @@ func TestIdentityGetAllFeatureStatesWithTraits(t *testing.T) { envWithSegmentOverride := fixtures.EnvironmentWithSegmentOverride(env, fixtures.SegmentOverrideFs(segment, feature1), segment) traitModels := []*traits.TraitModel{ - {TraitKey: fixtures.SegmentConditionProperty, TraitValue: fixtures.SegmentConditionStringValue}, + {Key: fixtures.SegmentConditionProperty, Value: fixtures.SegmentConditionStringValue}, } allFeatureStates := flagengine.GetIdentityFeatureStates(envWithSegmentOverride, identity, traitModels...) diff --git a/flagengine/identities/traits/models.go b/flagengine/identities/traits/models.go index ebd557b6..56260b24 100644 --- a/flagengine/identities/traits/models.go +++ b/flagengine/identities/traits/models.go @@ -2,12 +2,19 @@ package traits import ( "encoding/json" + "fmt" "strings" ) +// TraitModel is a flagsmith.Trait with a serialised Value. type TraitModel struct { - TraitKey string `json:"trait_key"` - TraitValue string `json:"trait_value"` + Key string `json:"trait_key"` + Value string `json:"trait_value"` +} + +// NewTrait serialises value into a TraitModel using fmt.Sprint. +func NewTrait(key string, value interface{}) *TraitModel { + return &TraitModel{key, fmt.Sprint(value)} } func (t *TraitModel) UnmarshalJSON(bytes []byte) error { @@ -21,7 +28,7 @@ func (t *TraitModel) UnmarshalJSON(bytes []byte) error { return err } - t.TraitKey = obj.Key - t.TraitValue = strings.Trim(string(obj.Val), `"`) + t.Key = obj.Key + t.Value = strings.Trim(string(obj.Val), `"`) return nil } diff --git a/flagengine/segments/evaluator.go b/flagengine/segments/evaluator.go index f0f22857..a18b292d 100644 --- a/flagengine/segments/evaluator.go +++ b/flagengine/segments/evaluator.go @@ -70,8 +70,8 @@ func traitsMatchSegmentCondition( } var matchedTraitValue *string for _, trait := range identityTraits { - if trait.TraitKey == condition.Property { - matchedTraitValue = &trait.TraitValue + if trait.Key == condition.Property { + matchedTraitValue = &trait.Value } } diff --git a/flagengine/segments/evaluator_test.go b/flagengine/segments/evaluator_test.go index 9b18dc28..2c6e94c6 100644 --- a/flagengine/segments/evaluator_test.go +++ b/flagengine/segments/evaluator_test.go @@ -172,39 +172,39 @@ func TestIdentityInSegment(t *testing.T) { {segment_single_condition, nil, false}, { segment_single_condition, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, + []*traits.TraitModel{{Key: trait_key_1, Value: trait_value_1}}, true, }, {segment_multiple_conditions_all, nil, false}, { segment_multiple_conditions_all, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, + []*traits.TraitModel{{Key: trait_key_1, Value: trait_value_1}}, false, }, { segment_multiple_conditions_all, []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, + {Key: trait_key_1, Value: trait_value_1}, + {Key: trait_key_2, Value: trait_value_2}, }, true, }, {segment_multiple_conditions_any, nil, false}, { segment_multiple_conditions_any, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, + []*traits.TraitModel{{Key: trait_key_1, Value: trait_value_1}}, true, }, { segment_multiple_conditions_any, - []*traits.TraitModel{{TraitKey: trait_key_2, TraitValue: trait_value_2}}, + []*traits.TraitModel{{Key: trait_key_2, Value: trait_value_2}}, true, }, { segment_multiple_conditions_all, []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, + {Key: trait_key_1, Value: trait_value_1}, + {Key: trait_key_2, Value: trait_value_2}, }, true, }, @@ -212,16 +212,16 @@ func TestIdentityInSegment(t *testing.T) { { segment_nested_rules, []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, + {Key: trait_key_1, Value: trait_value_1}, }, false, }, { segment_nested_rules, []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - {TraitKey: trait_key_3, TraitValue: trait_value_3}, + {Key: trait_key_1, Value: trait_value_1}, + {Key: trait_key_2, Value: trait_value_2}, + {Key: trait_key_3, Value: trait_value_3}, }, true, }, @@ -229,16 +229,16 @@ func TestIdentityInSegment(t *testing.T) { { segment_conditions_and_nested_rules, []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, + {Key: trait_key_1, Value: trait_value_1}, }, false, }, { segment_conditions_and_nested_rules, []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - {TraitKey: trait_key_3, TraitValue: trait_value_3}, + {Key: trait_key_1, Value: trait_value_1}, + {Key: trait_key_2, Value: trait_value_2}, + {Key: trait_key_3, Value: trait_value_3}, }, true, }, @@ -342,11 +342,11 @@ func TestIdentityInSegmentIsSetAndIsNotSet(t *testing.T) { identityTraits []*traits.TraitModel expectedResult bool }{ - {segments.IsSet, "foo", []*traits.TraitModel{{TraitKey: "foo", TraitValue: "bar"}}, true}, - {segments.IsSet, "foo", []*traits.TraitModel{{TraitKey: "not_foo", TraitValue: "bar"}}, false}, + {segments.IsSet, "foo", []*traits.TraitModel{{Key: "foo", Value: "bar"}}, true}, + {segments.IsSet, "foo", []*traits.TraitModel{{Key: "not_foo", Value: "bar"}}, false}, {segments.IsSet, "foo", []*traits.TraitModel{}, false}, {segments.IsNotSet, "foo", []*traits.TraitModel{}, true}, - {segments.IsNotSet, "foo", []*traits.TraitModel{{TraitKey: "foo", TraitValue: "bar"}}, false}, + {segments.IsNotSet, "foo", []*traits.TraitModel{{Key: "foo", Value: "bar"}}, false}, } for i, c := range cases { diff --git a/flagengine/utils/fixtures/fixtures.go b/flagengine/utils/fixtures/fixtures.go index 75bfca72..c29cb411 100644 --- a/flagengine/utils/fixtures/fixtures.go +++ b/flagengine/utils/fixtures/fixtures.go @@ -99,8 +99,8 @@ func Identity(env *environments.EnvironmentModel) *identities.IdentityModel { func TraitMatchingSegment(segCond *segments.SegmentConditionModel) *traits.TraitModel { return &traits.TraitModel{ - TraitKey: segCond.Property, - TraitValue: segCond.Value, + Key: segCond.Property, + Value: segCond.Value, } } @@ -112,7 +112,7 @@ func IdentityInSegment(trait *traits.TraitModel, env *environments.EnvironmentMo } } -func SegmentOverrideFs(segment *segments.SegmentModel, feature *features.FeatureModel) *features.FeatureStateModel { +func SegmentOverrideFs(_ *segments.SegmentModel, feature *features.FeatureModel) *features.FeatureStateModel { return &features.FeatureStateModel{ DjangoID: 4, Feature: feature, diff --git a/go.mod b/go.mod index 25708f06..27ef19c3 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/elastic/go-json-schema-generate v0.0.0-20220519132038-c708d18d6ca2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.27.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 7d98dad2..ff86d6db 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/elastic/go-json-schema-generate v0.0.0-20220519132038-c708d18d6ca2 h1:C8FTj5Y0BfGNSFyQI6V0HeATwKwTlLfU8qletYG7V8Y= -github.com/elastic/go-json-schema-generate v0.0.0-20220519132038-c708d18d6ca2/go.mod h1:w6t176CDaF2cZXwuQtFA5T+trYjvo5OYxLbBwAE7gxU= github.com/go-resty/resty/v2 v2.14.0 h1:/rhkzsAqGQkozwfKS5aFAbb6TyKd3zyFRWcdRXLPCAU= github.com/go-resty/resty/v2 v2.14.0/go.mod h1:IW6mekUOsElt9C7oWr0XRt9BNSD6D5rr9mhk6NjmNHg= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/logger.go b/logger.go index 2e876e1d..ea463e1f 100644 --- a/logger.go +++ b/logger.go @@ -1,51 +1,79 @@ package flagsmith import ( - "log" - "os" + "context" + "fmt" + "log/slog" + "time" + + "github.com/go-resty/resty/v2" ) -// Logger is the interface used for logging by flagsmith client. This interface defines the methods -// that a logger implementation must implement. It is used to abstract logging and -// enable clients to use any logger implementation they want. -type Logger interface { - // Errorf logs an error message with the given format and arguments. - Errorf(format string, v ...interface{}) +type contextKey string - // Warnf logs a warning message with the given format and arguments. - Warnf(format string, v ...interface{}) +const ( + contextLoggerKey contextKey = contextKey("logger") + contextStartTimeKey contextKey = contextKey("startTime") +) - // Debugf logs a debug message with the given format and arguments. - Debugf(format string, v ...interface{}) +// restySlogLogger implements a [resty.Logger] using a [slog.Logger]. +type restySlogLogger struct { + logger *slog.Logger } -func createLogger() *logger { - l := &logger{l: log.New(os.Stderr, "", log.Ldate|log.Lmicroseconds)} - return l +func (s restySlogLogger) Errorf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + s.logger.Error(msg) } -var _ Logger = (*logger)(nil) - -type logger struct { - l *log.Logger +func (s restySlogLogger) Warnf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + s.logger.Warn(msg) } -func (l *logger) Errorf(format string, v ...interface{}) { - l.output("ERROR FLAGSMITH: "+format, v...) +func (s restySlogLogger) Debugf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + s.logger.Debug(msg) } -func (l *logger) Warnf(format string, v ...interface{}) { - l.output("WARN FLAGSMITH: "+format, v...) -} +func newRestyLogRequestMiddleware(logger *slog.Logger) resty.RequestMiddleware { + return func(c *resty.Client, req *resty.Request) error { + // Create a child logger with request metadata + reqLogger := logger.WithGroup("http").With( + "method", req.Method, + "url", req.URL, + ) + reqLogger.Debug("request") -func (l *logger) Debugf(format string, v ...interface{}) { - l.output("DEBUG FLAGSMITH: "+format, v...) + // Store the logger in this request's context, and use it in the response + req.SetContext(context.WithValue(req.Context(), contextLoggerKey, reqLogger)) + + // Time the current request + req.SetContext(context.WithValue(req.Context(), contextStartTimeKey, time.Now())) + + return nil + } } -func (l *logger) output(format string, v ...interface{}) { - if len(v) == 0 { - l.l.Print(format) - return +func newRestyLogResponseMiddleware(logger *slog.Logger) resty.ResponseMiddleware { + return func(client *resty.Client, resp *resty.Response) error { + // Retrieve the logger and start time from context + reqLogger, _ := resp.Request.Context().Value(contextLoggerKey).(*slog.Logger) + startTime, _ := resp.Request.Context().Value(contextStartTimeKey).(time.Time) + + if reqLogger == nil { + reqLogger = logger + } + reqLogger = reqLogger.With( + slog.Int("status", resp.StatusCode()), + slog.Duration("duration", time.Since(startTime)), + slog.Int64("content_length", resp.Size()), + ) + if resp.IsError() { + reqLogger.Error("error response") + } else { + reqLogger.Debug("response") + } + return nil } - l.l.Printf(format, v...) } diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 00000000..c3fe04ae --- /dev/null +++ b/logger_test.go @@ -0,0 +1,16 @@ +package flagsmith + +import ( + "log/slog" + "os" + "testing" +) + +func TestMain(m *testing.M) { + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + slog.SetDefault(slog.New(handler)) + + os.Exit(m.Run()) +} diff --git a/models.go b/models.go index 1d4530c1..efa752fc 100644 --- a/models.go +++ b/models.go @@ -5,8 +5,6 @@ import ( "fmt" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" ) type Flag struct { @@ -18,9 +16,8 @@ type Flag struct { } type Trait struct { - TraitKey string `json:"trait_key"` - TraitValue interface{} `json:"trait_value"` - Transient bool `json:"transient,omitempty"` + Key string `json:"trait_key"` + Value interface{} `json:"trait_value"` } type IdentityTraits struct { @@ -29,13 +26,6 @@ type IdentityTraits struct { Transient bool `json:"transient,omitempty"` } -func (t *Trait) ToTraitModel() *traits.TraitModel { - return &traits.TraitModel{ - TraitKey: t.TraitKey, - TraitValue: fmt.Sprint(t.TraitValue), - } -} - func makeFlagFromFeatureState(featureState *features.FeatureStateModel, identityID string) Flag { return Flag{ Enabled: featureState.Enabled, @@ -154,7 +144,7 @@ func (f *Flags) GetFlag(featureName string) (Flag, error) { if f.defaultFlagHandler != nil { return f.defaultFlagHandler(featureName) } - return resultFlag, &FlagsmithClientError{fmt.Sprintf("flagsmith: No feature found with name %q", featureName)} + return resultFlag, fmt.Errorf("feature %q not found", featureName) } if f.analyticsProcessor != nil { f.analyticsProcessor.TrackFeature(resultFlag.FeatureName) diff --git a/offline_handler.go b/offline_handler.go index 3a69d19c..7689dfe3 100644 --- a/offline_handler.go +++ b/offline_handler.go @@ -7,34 +7,26 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" ) -type OfflineHandler interface { +type Environment interface { GetEnvironment() *environments.EnvironmentModel } -type LocalFileHandler struct { - environment *environments.EnvironmentModel +type environment struct { + model *environments.EnvironmentModel } -// NewLocalFileHandler creates a new LocalFileHandler with the given path. -func NewLocalFileHandler(environmentDocumentPath string) (*LocalFileHandler, error) { - // Read the environment document from the specified path - environmentDocument, err := os.ReadFile(environmentDocumentPath) +func (e environment) GetEnvironment() *environments.EnvironmentModel { + return e.model +} + +// ReadEnvironmentFromFile reads an Environment from a file path. +func ReadEnvironmentFromFile(name string) (env Environment, err error) { + file, err := os.ReadFile(name) if err != nil { return nil, err } - var environment environments.EnvironmentModel - if err := json.Unmarshal(environmentDocument, &environment); err != nil { - return nil, err - } - - // Create and initialise the LocalFileHandler - handler := &LocalFileHandler{ - environment: &environment, - } - - return handler, nil -} - -func (handler *LocalFileHandler) GetEnvironment() *environments.EnvironmentModel { - return handler.environment + var model environments.EnvironmentModel + err = json.Unmarshal(file, &model) + env = environment{model: &model} + return } diff --git a/offline_handler_test.go b/offline_handler_test.go index 1175ef07..29fbf024 100644 --- a/offline_handler_test.go +++ b/offline_handler_test.go @@ -12,7 +12,7 @@ func TestNewLocalFileHandler(t *testing.T) { envJsonPath := "./fixtures/environment.json" // When - offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) + offlineHandler, err := flagsmith.ReadEnvironmentFromFile(envJsonPath) // Then assert.NoError(t, err) @@ -22,13 +22,11 @@ func TestNewLocalFileHandler(t *testing.T) { func TestLocalFileHandlerGetEnvironment(t *testing.T) { // Given envJsonPath := "./fixtures/environment.json" - localHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) - - assert.NoError(t, err) // When - environment := localHandler.GetEnvironment() + env, err := flagsmith.ReadEnvironmentFromFile(envJsonPath) // Then - assert.NotNil(t, environment.APIKey) + assert.NoError(t, err) + assert.NotEmpty(t, env.GetEnvironment().APIKey) } diff --git a/options.go b/options.go index af919f9a..77a888f2 100644 --- a/options.go +++ b/options.go @@ -2,147 +2,150 @@ package flagsmith import ( "context" + "log/slog" "strings" "time" ) type Option func(c *Client) -// Make sure With* functions have correct type. -var _ = []Option{ - WithBaseURL(""), - WithLocalEvaluation(context.TODO()), - WithRemoteEvaluation(), - WithRequestTimeout(0), - WithEnvironmentRefreshInterval(0), - WithAnalytics(context.TODO()), - WithRetries(3, 1*time.Second), - WithCustomHeaders(nil), - WithDefaultHandler(nil), - WithProxy(""), - WithRealtime(), - WithRealtimeBaseURL(""), -} - +// WithBaseURL sets the base URL of the Flagsmith API. Required if using a Flagsmith instance other than +// https://app.flagsmith.com. +// +// Defaults to https://edge.api.flagsmith.com/api/v1/. +// +// To set the URL of the real-time flags service, use [WithRealtimeBaseURL]. func WithBaseURL(url string) Option { return func(c *Client) { - c.config.baseURL = url + c.baseURL = url } } -// WithLocalEvaluation enables local evaluation of the Feature flags. +// WithLocalEvaluation makes feature flags be evaluated locally instead of remotely by the Flagsmith API. It requires a +// server-side SDK key. // -// The goroutine responsible for asynchronously updating the environment makes -// use of the context provided here, which means that if it expires the -// background process will exit. +// Flags are evaluated locally by fetching the environment state from the Flagsmith API, and running the Flagsmith flag +// engine locally. When a [Client] is instantiated, a goroutine will be created using the provided context to poll +// the environment state for updates at regular intervals. The polling rate and retry behaviour can be configured +// using [WithEnvironmentRefreshInterval] and [WithRetries]. func WithLocalEvaluation(ctx context.Context) Option { return func(c *Client) { - c.config.localEvaluation = true + c.localEvaluation = true c.ctxLocalEval = ctx } } -func WithRemoteEvaluation() Option { - return func(c *Client) { - c.config.localEvaluation = false - } -} - +// WithRequestTimeout sets the request timeout for all HTTP requests. func WithRequestTimeout(timeout time.Duration) Option { return func(c *Client) { - c.client.SetTimeout(timeout) + c.timeout = timeout } } +// WithEnvironmentRefreshInterval sets the delay between polls to fetch the current environment state when using +// [WithLocalEvaluation] to be at most once per interval. func WithEnvironmentRefreshInterval(interval time.Duration) Option { return func(c *Client) { - c.config.envRefreshInterval = interval + c.envRefreshInterval = interval } } -// WithAnalytics enables tracking of the usage of the Feature flags. -// -// The goroutine responsible for asynchronously uploading the locally stored -// cache uses the context provided here, which means that if it expires the -// background process will exit. +// WithAnalytics makes the [Client] keep track of calls to [Flags.GetFlag], [Flags.IsFeatureEnabled] or +// [Flags.GetFeatureValue]. It will create a goroutine that periodically flushes this data to the Flagsmith API using +// the provided context. func WithAnalytics(ctx context.Context) Option { return func(c *Client) { - c.config.enableAnalytics = true c.ctxAnalytics = ctx } } -func WithRetries(count int, waitTime time.Duration) Option { +// WithRetries makes the [Client] retry all failed HTTP requests n times, waiting for waitTime between retries. +func WithRetries(n int, waitTime time.Duration) Option { return func(c *Client) { - c.client.SetRetryCount(count) + c.client.SetRetryCount(n) c.client.SetRetryWaitTime(waitTime) } } +// WithCustomHeaders applies a set of HTTP headers on all requests made by the [Client]. func WithCustomHeaders(headers map[string]string) Option { return func(c *Client) { c.client.SetHeaders(headers) } } +// WithDefaultHandler sets a handler function used to return fallback values when [Client.GetFlags] would have normally +// returned an error. For example, this handler makes all flags be disabled by default: +// +// func handler(flagKey string) (Flag, error) { +// return Flag{ +// FeatureName: flagKey, +// Enabled: false, +// }, nil +// } func WithDefaultHandler(handler func(string) (Flag, error)) Option { - return func(c *Client) { - c.defaultFlagHandler = handler + f := func(flagKey string) (flag Flag, err error) { + flag, err = handler(flagKey) + flag.IsDefault = true + flag.FeatureName = flagKey + return flag, err } -} - -// Allows the client to use any logger that implements the `Logger` interface. -func WithLogger(logger Logger) Option { return func(c *Client) { - c.log = logger + c.defaultFlagHandler = f } } -// WithProxy returns an Option function that sets the proxy(to be used by internal resty client). -// The proxyURL argument is a string representing the URL of the proxy server to use, e.g. "http://proxy.example.com:8080". -func WithProxy(proxyURL string) Option { +// WithLogger sets a custom [slog.Logger] for the [Client]. +func WithLogger(logger *slog.Logger) Option { return func(c *Client) { - c.client.SetProxy(proxyURL) + c.log = logger } } -// WithOfflineHandler returns an Option function that sets the offline handler. -func WithOfflineHandler(handler OfflineHandler) Option { +// WithProxy sets a proxy server to use for all HTTP requests. +func WithProxy(url string) Option { return func(c *Client) { - c.offlineHandler = handler + c.proxy = url } } -// WithOfflineMode returns an Option function that enables the offline mode. -// NOTE: before using this option, you should set the offline handler. -func WithOfflineMode() Option { +// WithOfflineEnvironment sets the current environment and prevents Client from making network requests. +func WithOfflineEnvironment(env Environment) Option { return func(c *Client) { - c.config.offlineMode = true + c.state.SetOfflineEnvironment(env.GetEnvironment()) } } -// WithErrorHandler provides a way to handle errors that occur during update of an environment. -func WithErrorHandler(handler func(handler *FlagsmithAPIError)) Option { +// WithErrorHandler sets an error handler that is called if [Client.UpdateEnvironment] returns an error. +func WithErrorHandler(handler func(handler *APIError)) Option { return func(c *Client) { c.errorHandler = handler } } -// WithRealtime returns an Option function that enables real-time updates for the Client. -// NOTE: Before enabling real-time updates, ensure that local evaluation is enabled. +// WithRealtime enables real-time flag updates. It requires [WithLocalEvaluation]. +// +// When [Client] is constructed, a server-sent events (SSE) connection will be kept open in a goroutine using the +// same context used by [WithLocalEvaluation]. +// +// If you are using a Flagsmith instance other than https://app.flagsmith.com, use [WithRealtimeBaseURL] to set the URL +// of your real-time updates service. func WithRealtime() Option { return func(c *Client) { - c.config.useRealtime = true + c.useRealtime = true } } -// WithRealtimeBaseURL returns an Option function for configuring the real-time base URL of the Client. +// WithRealtimeBaseURL sets a custom URL to use for subscribing to real-time flag updates. This is required if you are +// using a Flagsmith instance other than https://app.flagsmith.com. +// +// The default base URL is https://realtime.flagsmith.com/. func WithRealtimeBaseURL(url string) Option { return func(c *Client) { // Ensure the URL ends with a trailing slash if !strings.HasSuffix(url, "/") { url += "/" } - c.config.realtimeBaseUrl = url + c.realtimeBaseUrl = url } } diff --git a/realtime.go b/realtime.go index 5227537d..b8cc70ea 100644 --- a/realtime.go +++ b/realtime.go @@ -5,61 +5,78 @@ import ( "context" "encoding/json" "errors" + "log/slog" "net/http" + "net/url" "strings" "time" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" ) func (c *Client) startRealtimeUpdates(ctx context.Context) { err := c.UpdateEnvironment(ctx) - if err != nil { + env := c.state.GetEnvironment() + if err != nil || env == nil { panic("Failed to fetch the environment while configuring real-time updates") } - env, _ := c.environment.Load().(*environments.EnvironmentModel) - stream_url := c.config.realtimeBaseUrl + "sse/environments/" + env.APIKey + "/stream" + envUpdatedAt := env.UpdatedAt + log := c.log.With("environment", env.APIKey, "current_updated_at", &envUpdatedAt) + + streamPath, err := url.JoinPath(c.realtimeBaseUrl, "sse/environments", env.APIKey, "stream") + if err != nil { + log.Error("failed to build stream URL", "error", err) + panic(err) + } + for { select { case <-ctx.Done(): return default: - resp, err := http.Get(stream_url) + resp, err := http.Get(streamPath) if err != nil { - c.log.Errorf("Error connecting to realtime server: %v", err) + log.Error("failed to connect to realtime service", "error", err) continue } defer resp.Body.Close() + log.Info("connected to realtime") + scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, "data: ") { - parsedTime, err := parseUpdatedAtFromSSE(line) + parsedTime, err := parseUpdatedAtFromSSE(line) + if err != nil { + log.Error("failed to parse realtime update event", "error", err, "raw_event", line) + continue + } + if parsedTime.After(envUpdatedAt) { + log.WithGroup("event"). + Info("received update event", + slog.Duration("update_delay", parsedTime.Sub(envUpdatedAt)), + slog.Time("updated_at", parsedTime), + slog.String("environment", env.APIKey), + ) + err = c.UpdateEnvironment(ctx) if err != nil { - c.log.Errorf("Error reading realtime stream: %v", err) + log.Error("realtime update failed", "error", err) continue } - if parsedTime.After(envUpdatedAt) { - err = c.UpdateEnvironment(ctx) - if err != nil { - c.log.Errorf("Failed to update the environment: %v", err) - continue - } - env, _ := c.environment.Load().(*environments.EnvironmentModel) - - envUpdatedAt = env.UpdatedAt - } + envUpdatedAt = parsedTime } } if err := scanner.Err(); err != nil { - c.log.Errorf("Error reading realtime stream: %v", err) + log.Error("failed to read from realtime stream", "error", err) } } } } + func parseUpdatedAtFromSSE(line string) (time.Time, error) { + if !strings.HasPrefix(line, "data: ") { + return time.Time{}, nil + } + var eventData struct { UpdatedAt float64 `json:"updated_at"` } @@ -67,11 +84,11 @@ func parseUpdatedAtFromSSE(line string) (time.Time, error) { data := strings.TrimPrefix(line, "data: ") err := json.Unmarshal([]byte(data), &eventData) if err != nil { - return time.Time{}, errors.New("failed to parse event data: " + err.Error()) + return time.Time{}, err } if eventData.UpdatedAt <= 0 { - return time.Time{}, errors.New("invalid 'updated_at' value in event data") + return time.Time{}, errors.New("updated_at is <= 0") } // Convert the float timestamp into seconds and nanoseconds diff --git a/state.go b/state.go new file mode 100644 index 00000000..9a192d9d --- /dev/null +++ b/state.go @@ -0,0 +1,56 @@ +package flagsmith + +import ( + "sync" + + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" +) + +// environmentState is a locally cached environments.EnvironmentModel. +type environmentState struct { + environment *environments.EnvironmentModel + offline bool + mu sync.RWMutex + + identityOverrides sync.Map +} + +// GetEnvironment returns the current environment and indicates if it was initialised. +func (cs *environmentState) GetEnvironment() *environments.EnvironmentModel { + cs.mu.RLock() + defer cs.mu.RUnlock() + return cs.environment +} + +func (cs *environmentState) GetIdentityOverride(identifier string) *identities.IdentityModel { + cs.mu.RLock() + defer cs.mu.RUnlock() + i, ok := cs.identityOverrides.Load(identifier) + if ok && i != nil { + return i.(*identities.IdentityModel) + } + return nil +} + +func (cs *environmentState) SetEnvironment(env *environments.EnvironmentModel) { + cs.mu.Lock() + defer cs.mu.Unlock() + + cs.environment = env + + // clear previous overrides before storing the new ones + cs.identityOverrides = sync.Map{} + for _, id := range env.IdentityOverrides { + cs.identityOverrides.Store(id.Identifier, id) + } +} + +func (cs *environmentState) SetOfflineEnvironment(env *environments.EnvironmentModel) { + cs.SetEnvironment(env) + cs.offline = true +} + +func (cs *environmentState) IsOffline() bool { + return cs.offline +} diff --git a/utils.go b/utils.go deleted file mode 100644 index b19fffce..00000000 --- a/utils.go +++ /dev/null @@ -1,34 +0,0 @@ -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} - } - if tCtx.Transient == nil { - return &Trait{TraitKey: tKey, TraitValue: tCtx.Value} - } - 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 -}