Skip to content

Commit

Permalink
feat(offline-mode): Add support offline mode using json file (#109)
Browse files Browse the repository at this point in the history
* feat(offline-mode): Add support for offline mode
  • Loading branch information
gagantrivedi authored Jan 11, 2024
1 parent 6e93e4e commit 9f959fc
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 26 additions & 8 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.")
Expand All @@ -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
}
Expand All @@ -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)}
Expand All @@ -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
}
Expand All @@ -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)}
Expand Down
137 changes: 137 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type config struct {
localEvaluation bool
envRefreshInterval time.Duration
enableAnalytics bool
offlineMode bool
}

// defaultConfig returns default configuration.
Expand Down
58 changes: 58 additions & 0 deletions fixtures/environment.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
40 changes: 40 additions & 0 deletions offline_handler.go
Original file line number Diff line number Diff line change
@@ -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
}
34 changes: 34 additions & 0 deletions offline_handler_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 15 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

0 comments on commit 9f959fc

Please sign in to comment.