diff --git a/client.go b/client.go index 2440735..ce42e5d 100644 --- a/client.go +++ b/client.go @@ -27,10 +27,11 @@ type Client struct { analyticsProcessor *AnalyticsProcessor defaultFlagHandler func(string) (Flag, error) - client flaghttp.Client - ctxLocalEval context.Context - ctxAnalytics context.Context - log Logger + client flaghttp.Client + ctxLocalEval context.Context + ctxAnalytics context.Context + log Logger + offlineHandler OfflineHandler } // NewClient creates instance of Client with given configuration. @@ -60,6 +61,17 @@ func NewClient(apiKey string, options ...Option) *Client { go c.pollEnvironment(c.ctxLocalEval) } + if c.config.offlineMode && c.offlineHandler == nil { + panic("offline handler must be provided to use offline mode.") + } + if c.defaultFlagHandler != nil && c.offlineHandler != nil { + panic("default flag handler and offline handler cannot be used together.") + } + if c.offlineHandler != nil { + c.environment.Store(c.offlineHandler.GetEnvironment()) + + } + // Initialize analytics processor if c.config.enableAnalytics { c.analyticsProcessor = NewAnalyticsProcessor(c.ctxAnalytics, c.client, c.config.baseURL, nil, c.log) @@ -74,7 +86,7 @@ func NewClient(apiKey string, options ...Option) *Client { // directly, but instead read the asynchronously updated local environment or // use the default flag handler in case it has not yet been updated. func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { - if c.config.localEvaluation { + if c.config.localEvaluation || c.config.offlineMode { if f, err = c.getEnvironmentFlagsFromEnvironment(); err == nil { return f, nil } @@ -83,7 +95,9 @@ func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { return f, nil } } - if c.defaultFlagHandler != nil { + if c.offlineHandler != nil { + return c.getEnvironmentFlagsFromEnvironment() + } else if c.defaultFlagHandler != nil { return Flags{defaultFlagHandler: c.defaultFlagHandler}, nil } return Flags{}, &FlagsmithClientError{msg: fmt.Sprintf("Failed to fetch flags with error: %s", err)} @@ -100,7 +114,7 @@ func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) { // directly, but instead read the asynchronously updated local environment or // use the default flag handler in case it has not yet been updated. func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits []*Trait) (f Flags, err error) { - if c.config.localEvaluation { + if c.config.localEvaluation || c.config.offlineMode { if f, err = c.getIdentityFlagsFromEnvironment(identifier, traits); err == nil { return f, nil } @@ -109,7 +123,9 @@ func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits return f, nil } } - if c.defaultFlagHandler != nil { + if c.offlineHandler != nil { + return c.getIdentityFlagsFromEnvironment(identifier, traits) + } else if c.defaultFlagHandler != nil { return Flags{defaultFlagHandler: c.defaultFlagHandler}, nil } return Flags{}, &FlagsmithClientError{msg: fmt.Sprintf("Failed to fetch flags with error: %s", err)} diff --git a/client_test.go b/client_test.go index 68f5273..1691606 100644 --- a/client_test.go +++ b/client_test.go @@ -23,6 +23,45 @@ func TestClientErrorsIfLocalEvaluationWithNonServerSideKey(t *testing.T) { }) } +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 TestClientUpdatesEnvironmentOnStartForLocalEvaluation(t *testing.T) { // Given ctx := context.Background() @@ -498,3 +537,68 @@ func TestWithProxyClientOption(t *testing.T) { assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID) assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value) } + +func TestOfflineMode(t *testing.T) { + // Given + ctx := context.Background() + + envJsonPath := "./fixtures/environment.json" + offlineHandler, err := flagsmith.NewLocalFileHandler(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 should work 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() + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithOfflineHandler(offlineHandler), + flagsmith.WithBaseURL(server.URL+"/api/v1/")) + + // 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) +} diff --git a/config.go b/config.go index 8597741..27bbb16 100644 --- a/config.go +++ b/config.go @@ -22,6 +22,7 @@ type config struct { localEvaluation bool envRefreshInterval time.Duration enableAnalytics bool + offlineMode bool } // defaultConfig returns default configuration. diff --git a/fixtures/environment.json b/fixtures/environment.json new file mode 100644 index 0000000..cd71ecf --- /dev/null +++ b/fixtures/environment.json @@ -0,0 +1,58 @@ +{ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [ + { + "id": 1, + "name": "Test Segment", + "feature_states": [], + "rules": [ + { + "type": "ALL", + "conditions": [], + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "operator": "EQUAL", + "property_": "foo", + "value": "bar" + } + ] + } + ] + } + ] + } + ] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": "some_value", + "id": 1, + "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", + "feature": { + "name": "feature_1", + "type": "STANDARD", + "id": 1 + }, + "segment_id": null, + "enabled": true + } + ] +} diff --git a/offline_handler.go b/offline_handler.go new file mode 100644 index 0000000..4154452 --- /dev/null +++ b/offline_handler.go @@ -0,0 +1,39 @@ +package flagsmith + +import ( + "encoding/json" + "os" + + "github.com/Flagsmith/flagsmith-go-client/v3/flagengine/environments" +) + +type OfflineHandler interface { + GetEnvironment() *environments.EnvironmentModel +} + +type LocalFileHandler struct { + environment *environments.EnvironmentModel +} + +func NewLocalFileHandler(environmentDocumentPath string) (*LocalFileHandler, error) { + // Read the environment document from the specified path + environmentDocument, err := os.ReadFile(environmentDocumentPath) + if err != nil { + return nil, err + } + var environment environments.EnvironmentModel + if err := json.Unmarshal(environmentDocument, &environment); err != nil { + return nil, err + } + + // Create and initialize the LocalFileHandler + handler := &LocalFileHandler{ + environment: &environment, + } + + return handler, nil +} + +func (handler *LocalFileHandler) GetEnvironment() *environments.EnvironmentModel { + return handler.environment +} diff --git a/offline_handler_test.go b/offline_handler_test.go new file mode 100644 index 0000000..9650aab --- /dev/null +++ b/offline_handler_test.go @@ -0,0 +1,35 @@ +package flagsmith_test + +import ( + "testing" + + flagsmith "github.com/Flagsmith/flagsmith-go-client/v3" + "github.com/stretchr/testify/assert" +) + +func TestNewLocalFileHandler(t *testing.T) { + // Given + envJsonPath := "./fixtures/environment.json" + + // When + offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) + + // Then + assert.NoError(t, err) + assert.NotNil(t, offlineHandler) + +} + +func TestLocalFileHandlerGetEnvironment(t *testing.T) { + // Given + envJsonPath := "./fixtures/environment.json" + localHandler, err := flagsmith.NewLocalFileHandler(envJsonPath) + + assert.NoError(t, err) + + // When + environment := localHandler.GetEnvironment() + + // Then + assert.NotNil(t, environment.APIKey) +} diff --git a/options.go b/options.go index 429251d..21a8bb6 100644 --- a/options.go +++ b/options.go @@ -102,3 +102,14 @@ func WithProxy(proxyURL string) Option { c.client.SetProxy(proxyURL) } } + +func WithOfflineHandler(handler OfflineHandler) Option { + return func(c *Client) { + c.offlineHandler = handler + } +} +func WithOfflineMode() Option { + return func(c *Client) { + c.config.offlineMode = true + } +}