diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 6022e4e..5ab3f1d 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v5 with: - go-version: '1.18' + go-version: '1.19' id: go - name: Check out code into the Go module directory diff --git a/client.go b/client.go index 2440735..a8bf183 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. @@ -53,6 +54,19 @@ func NewClient(apiKey string, options ...Option) *Client { } c.client.SetLogger(c.log) + 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.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()) + } + if c.config.localEvaluation { if !strings.HasPrefix(apiKey, "ser.") { panic("In order to use local evaluation, please generate a server key in the environment settings page.") @@ -74,7 +88,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 +97,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 +116,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 +125,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..59cd8e0 100644 --- a/client_test.go +++ b/client_test.go @@ -23,6 +23,66 @@ 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 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() @@ -498,3 +558,80 @@ 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 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/")) + + // 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) +} 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..3e56cf2 --- /dev/null +++ b/offline_handler.go @@ -0,0 +1,40 @@ +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 +} + +// 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) + 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..549f744 --- /dev/null +++ b/offline_handler_test.go @@ -0,0 +1,34 @@ +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..13660df 100644 --- a/options.go +++ b/options.go @@ -102,3 +102,18 @@ func WithProxy(proxyURL string) Option { c.client.SetProxy(proxyURL) } } + +// WithOfflineHandler returns an Option function that sets the offline handler. +func WithOfflineHandler(handler OfflineHandler) Option { + return func(c *Client) { + c.offlineHandler = handler + } +} + +// WithOfflineMode returns an Option function that enables the offline mode. +// NOTE: before using this option, you should set the offline handler. +func WithOfflineMode() Option { + return func(c *Client) { + c.config.offlineMode = true + } +}