Skip to content

Commit 9f959fc

Browse files
authored
feat(offline-mode): Add support offline mode using json file (#109)
* feat(offline-mode): Add support for offline mode
1 parent 6e93e4e commit 9f959fc

File tree

8 files changed

+312
-9
lines changed

8 files changed

+312
-9
lines changed

.github/workflows/go.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Set up Go 1.x
1717
uses: actions/setup-go@v5
1818
with:
19-
go-version: '1.18'
19+
go-version: '1.19'
2020
id: go
2121

2222
- name: Check out code into the Go module directory

client.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ type Client struct {
2727
analyticsProcessor *AnalyticsProcessor
2828
defaultFlagHandler func(string) (Flag, error)
2929

30-
client flaghttp.Client
31-
ctxLocalEval context.Context
32-
ctxAnalytics context.Context
33-
log Logger
30+
client flaghttp.Client
31+
ctxLocalEval context.Context
32+
ctxAnalytics context.Context
33+
log Logger
34+
offlineHandler OfflineHandler
3435
}
3536

3637
// NewClient creates instance of Client with given configuration.
@@ -53,6 +54,19 @@ func NewClient(apiKey string, options ...Option) *Client {
5354
}
5455
c.client.SetLogger(c.log)
5556

57+
if c.config.offlineMode && c.offlineHandler == nil {
58+
panic("offline handler must be provided to use offline mode.")
59+
}
60+
if c.defaultFlagHandler != nil && c.offlineHandler != nil {
61+
panic("default flag handler and offline handler cannot be used together.")
62+
}
63+
if c.config.localEvaluation && c.offlineHandler != nil {
64+
panic("local evaluation and offline handler cannot be used together.")
65+
}
66+
if c.offlineHandler != nil {
67+
c.environment.Store(c.offlineHandler.GetEnvironment())
68+
}
69+
5670
if c.config.localEvaluation {
5771
if !strings.HasPrefix(apiKey, "ser.") {
5872
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 {
7488
// directly, but instead read the asynchronously updated local environment or
7589
// use the default flag handler in case it has not yet been updated.
7690
func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) {
77-
if c.config.localEvaluation {
91+
if c.config.localEvaluation || c.config.offlineMode {
7892
if f, err = c.getEnvironmentFlagsFromEnvironment(); err == nil {
7993
return f, nil
8094
}
@@ -83,7 +97,9 @@ func (c *Client) GetEnvironmentFlags(ctx context.Context) (f Flags, err error) {
8397
return f, nil
8498
}
8599
}
86-
if c.defaultFlagHandler != nil {
100+
if c.offlineHandler != nil {
101+
return c.getEnvironmentFlagsFromEnvironment()
102+
} else if c.defaultFlagHandler != nil {
87103
return Flags{defaultFlagHandler: c.defaultFlagHandler}, nil
88104
}
89105
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) {
100116
// directly, but instead read the asynchronously updated local environment or
101117
// use the default flag handler in case it has not yet been updated.
102118
func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits []*Trait) (f Flags, err error) {
103-
if c.config.localEvaluation {
119+
if c.config.localEvaluation || c.config.offlineMode {
104120
if f, err = c.getIdentityFlagsFromEnvironment(identifier, traits); err == nil {
105121
return f, nil
106122
}
@@ -109,7 +125,9 @@ func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits
109125
return f, nil
110126
}
111127
}
112-
if c.defaultFlagHandler != nil {
128+
if c.offlineHandler != nil {
129+
return c.getIdentityFlagsFromEnvironment(identifier, traits)
130+
} else if c.defaultFlagHandler != nil {
113131
return Flags{defaultFlagHandler: c.defaultFlagHandler}, nil
114132
}
115133
return Flags{}, &FlagsmithClientError{msg: fmt.Sprintf("Failed to fetch flags with error: %s", err)}

client_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,66 @@ func TestClientErrorsIfLocalEvaluationWithNonServerSideKey(t *testing.T) {
2323
})
2424
}
2525

26+
func TestClientErrorsIfOfflineModeWithoutOfflineHandler(t *testing.T) {
27+
// When
28+
defer func() {
29+
if r := recover(); r != nil {
30+
// Then
31+
errMsg := fmt.Sprintf("%v", r)
32+
expectedErrMsg := "offline handler must be provided to use offline mode."
33+
assert.Equal(t, expectedErrMsg, errMsg, "Unexpected error message")
34+
}
35+
}()
36+
37+
// Trigger panic
38+
_ = flagsmith.NewClient("key", flagsmith.WithOfflineMode())
39+
}
40+
41+
func TestClientErrorsIfDefaultHandlerAndOfflineHandlerAreBothSet(t *testing.T) {
42+
// Given
43+
envJsonPath := "./fixtures/environment.json"
44+
offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath)
45+
assert.NoError(t, err)
46+
47+
// When
48+
defer func() {
49+
if r := recover(); r != nil {
50+
// Then
51+
errMsg := fmt.Sprintf("%v", r)
52+
expectedErrMsg := "default flag handler and offline handler cannot be used together."
53+
assert.Equal(t, expectedErrMsg, errMsg, "Unexpected error message")
54+
}
55+
}()
56+
57+
// Trigger panic
58+
_ = flagsmith.NewClient("key",
59+
flagsmith.WithOfflineHandler(offlineHandler),
60+
flagsmith.WithDefaultHandler(func(featureName string) (flagsmith.Flag, error) {
61+
return flagsmith.Flag{IsDefault: true}, nil
62+
}))
63+
}
64+
func TestClientErrorsIfLocalEvaluationModeAndOfflineHandlerAreBothSet(t *testing.T) {
65+
// Given
66+
envJsonPath := "./fixtures/environment.json"
67+
offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath)
68+
assert.NoError(t, err)
69+
70+
// When
71+
defer func() {
72+
if r := recover(); r != nil {
73+
// Then
74+
errMsg := fmt.Sprintf("%v", r)
75+
expectedErrMsg := "local evaluation and offline handler cannot be used together."
76+
assert.Equal(t, expectedErrMsg, errMsg, "Unexpected error message")
77+
}
78+
}()
79+
80+
// Trigger panic
81+
_ = flagsmith.NewClient("key",
82+
flagsmith.WithOfflineHandler(offlineHandler),
83+
flagsmith.WithLocalEvaluation(context.Background()))
84+
}
85+
2686
func TestClientUpdatesEnvironmentOnStartForLocalEvaluation(t *testing.T) {
2787
// Given
2888
ctx := context.Background()
@@ -498,3 +558,80 @@ func TestWithProxyClientOption(t *testing.T) {
498558
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
499559
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
500560
}
561+
562+
func TestOfflineMode(t *testing.T) {
563+
// Given
564+
ctx := context.Background()
565+
566+
envJsonPath := "./fixtures/environment.json"
567+
offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath)
568+
assert.NoError(t, err)
569+
570+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithOfflineMode(), flagsmith.WithOfflineHandler(offlineHandler))
571+
572+
// Then
573+
flags, err := client.GetEnvironmentFlags(ctx)
574+
assert.NoError(t, err)
575+
576+
allFlags := flags.AllFlags()
577+
578+
assert.Equal(t, 1, len(allFlags))
579+
580+
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
581+
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
582+
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
583+
584+
// And GetIdentityFlags works as well
585+
flags, err = client.GetIdentityFlags(ctx, "test_identity", nil)
586+
assert.NoError(t, err)
587+
588+
allFlags = flags.AllFlags()
589+
590+
assert.Equal(t, 1, len(allFlags))
591+
592+
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
593+
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
594+
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
595+
}
596+
597+
func TestOfflineHandlerIsUsedWhenRequestFails(t *testing.T) {
598+
// Given
599+
ctx := context.Background()
600+
601+
envJsonPath := "./fixtures/environment.json"
602+
offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath)
603+
assert.NoError(t, err)
604+
605+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
606+
rw.WriteHeader(http.StatusInternalServerError)
607+
}))
608+
defer server.Close()
609+
610+
// When
611+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, flagsmith.WithOfflineHandler(offlineHandler),
612+
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
613+
614+
// Then
615+
flags, err := client.GetEnvironmentFlags(ctx)
616+
assert.NoError(t, err)
617+
618+
allFlags := flags.AllFlags()
619+
620+
assert.Equal(t, 1, len(allFlags))
621+
622+
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
623+
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
624+
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
625+
626+
// And GetIdentityFlags works as well
627+
flags, err = client.GetIdentityFlags(ctx, "test_identity", nil)
628+
assert.NoError(t, err)
629+
630+
allFlags = flags.AllFlags()
631+
632+
assert.Equal(t, 1, len(allFlags))
633+
634+
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
635+
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
636+
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
637+
}

config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type config struct {
2222
localEvaluation bool
2323
envRefreshInterval time.Duration
2424
enableAnalytics bool
25+
offlineMode bool
2526
}
2627

2728
// defaultConfig returns default configuration.

fixtures/environment.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"api_key": "B62qaMZNwfiqT76p38ggrQ",
3+
"project": {
4+
"name": "Test project",
5+
"organisation": {
6+
"feature_analytics": false,
7+
"name": "Test Org",
8+
"id": 1,
9+
"persist_trait_data": true,
10+
"stop_serving_flags": false
11+
},
12+
"id": 1,
13+
"hide_disabled_flags": false,
14+
"segments": [
15+
{
16+
"id": 1,
17+
"name": "Test Segment",
18+
"feature_states": [],
19+
"rules": [
20+
{
21+
"type": "ALL",
22+
"conditions": [],
23+
"rules": [
24+
{
25+
"type": "ALL",
26+
"rules": [],
27+
"conditions": [
28+
{
29+
"operator": "EQUAL",
30+
"property_": "foo",
31+
"value": "bar"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
]
38+
}
39+
]
40+
},
41+
"segment_overrides": [],
42+
"id": 1,
43+
"feature_states": [
44+
{
45+
"multivariate_feature_state_values": [],
46+
"feature_state_value": "some_value",
47+
"id": 1,
48+
"featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51",
49+
"feature": {
50+
"name": "feature_1",
51+
"type": "STANDARD",
52+
"id": 1
53+
},
54+
"segment_id": null,
55+
"enabled": true
56+
}
57+
]
58+
}

offline_handler.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package flagsmith
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
7+
"github.com/Flagsmith/flagsmith-go-client/v3/flagengine/environments"
8+
)
9+
10+
type OfflineHandler interface {
11+
GetEnvironment() *environments.EnvironmentModel
12+
}
13+
14+
type LocalFileHandler struct {
15+
environment *environments.EnvironmentModel
16+
}
17+
18+
// NewLocalFileHandler creates a new LocalFileHandler with the given path.
19+
func NewLocalFileHandler(environmentDocumentPath string) (*LocalFileHandler, error) {
20+
// Read the environment document from the specified path
21+
environmentDocument, err := os.ReadFile(environmentDocumentPath)
22+
if err != nil {
23+
return nil, err
24+
}
25+
var environment environments.EnvironmentModel
26+
if err := json.Unmarshal(environmentDocument, &environment); err != nil {
27+
return nil, err
28+
}
29+
30+
// Create and initialize the LocalFileHandler
31+
handler := &LocalFileHandler{
32+
environment: &environment,
33+
}
34+
35+
return handler, nil
36+
}
37+
38+
func (handler *LocalFileHandler) GetEnvironment() *environments.EnvironmentModel {
39+
return handler.environment
40+
}

offline_handler_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package flagsmith_test
2+
3+
import (
4+
"testing"
5+
6+
flagsmith "github.com/Flagsmith/flagsmith-go-client/v3"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestNewLocalFileHandler(t *testing.T) {
11+
// Given
12+
envJsonPath := "./fixtures/environment.json"
13+
14+
// When
15+
offlineHandler, err := flagsmith.NewLocalFileHandler(envJsonPath)
16+
17+
// Then
18+
assert.NoError(t, err)
19+
assert.NotNil(t, offlineHandler)
20+
}
21+
22+
func TestLocalFileHandlerGetEnvironment(t *testing.T) {
23+
// Given
24+
envJsonPath := "./fixtures/environment.json"
25+
localHandler, err := flagsmith.NewLocalFileHandler(envJsonPath)
26+
27+
assert.NoError(t, err)
28+
29+
// When
30+
environment := localHandler.GetEnvironment()
31+
32+
// Then
33+
assert.NotNil(t, environment.APIKey)
34+
}

options.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,18 @@ func WithProxy(proxyURL string) Option {
102102
c.client.SetProxy(proxyURL)
103103
}
104104
}
105+
106+
// WithOfflineHandler returns an Option function that sets the offline handler.
107+
func WithOfflineHandler(handler OfflineHandler) Option {
108+
return func(c *Client) {
109+
c.offlineHandler = handler
110+
}
111+
}
112+
113+
// WithOfflineMode returns an Option function that enables the offline mode.
114+
// NOTE: before using this option, you should set the offline handler.
115+
func WithOfflineMode() Option {
116+
return func(c *Client) {
117+
c.config.offlineMode = true
118+
}
119+
}

0 commit comments

Comments
 (0)