Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(offline-mode): Add support offline mode using json file #109

Merged
merged 5 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
Loading