Skip to content

Commit cc7161c

Browse files
authored
[FSSDK-11160] Add cmab service (#401)
* add cmab service * update attributes to attributeIds also for datafile * Remove cmab_test.go as it's no longer needed * refactor per PR comments * add two tests * refactor per PR comments * add missing unit tests to fill in test coverage * add missing tests for coveralls increase
1 parent 081b394 commit cc7161c

File tree

11 files changed

+1227
-5
lines changed

11 files changed

+1227
-5
lines changed

pkg/client/factory.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,12 @@ func convertDecideOptions(options []decide.OptimizelyDecideOptions) *decide.Opti
406406
finalOptions.IncludeReasons = true
407407
case decide.ExcludeVariables:
408408
finalOptions.ExcludeVariables = true
409+
case decide.IgnoreCMABCache:
410+
finalOptions.IgnoreCMABCache = true
411+
case decide.ResetCMABCache:
412+
finalOptions.ResetCMABCache = true
413+
case decide.InvalidateUserCMABCache:
414+
finalOptions.InvalidateUserCMABCache = true
409415
}
410416
}
411417
return &finalOptions

pkg/client/factory_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,3 +385,52 @@ func TestOptimizelyClientWithNoTracer(t *testing.T) {
385385
tracer := optimizelyClient.tracer.(*tracing.NoopTracer)
386386
assert.NotNil(t, tracer)
387387
}
388+
389+
func TestConvertDecideOptionsWithCMABOptions(t *testing.T) {
390+
// Test with IgnoreCMABCache option
391+
options := []decide.OptimizelyDecideOptions{decide.IgnoreCMABCache}
392+
convertedOptions := convertDecideOptions(options)
393+
assert.True(t, convertedOptions.IgnoreCMABCache)
394+
assert.False(t, convertedOptions.ResetCMABCache)
395+
assert.False(t, convertedOptions.InvalidateUserCMABCache)
396+
397+
// Test with ResetCMABCache option
398+
options = []decide.OptimizelyDecideOptions{decide.ResetCMABCache}
399+
convertedOptions = convertDecideOptions(options)
400+
assert.False(t, convertedOptions.IgnoreCMABCache)
401+
assert.True(t, convertedOptions.ResetCMABCache)
402+
assert.False(t, convertedOptions.InvalidateUserCMABCache)
403+
404+
// Test with InvalidateUserCMABCache option
405+
options = []decide.OptimizelyDecideOptions{decide.InvalidateUserCMABCache}
406+
convertedOptions = convertDecideOptions(options)
407+
assert.False(t, convertedOptions.IgnoreCMABCache)
408+
assert.False(t, convertedOptions.ResetCMABCache)
409+
assert.True(t, convertedOptions.InvalidateUserCMABCache)
410+
411+
// Test with all CMAB options
412+
options = []decide.OptimizelyDecideOptions{
413+
decide.IgnoreCMABCache,
414+
decide.ResetCMABCache,
415+
decide.InvalidateUserCMABCache,
416+
}
417+
convertedOptions = convertDecideOptions(options)
418+
assert.True(t, convertedOptions.IgnoreCMABCache)
419+
assert.True(t, convertedOptions.ResetCMABCache)
420+
assert.True(t, convertedOptions.InvalidateUserCMABCache)
421+
422+
// Test with CMAB options mixed with other options
423+
options = []decide.OptimizelyDecideOptions{
424+
decide.DisableDecisionEvent,
425+
decide.IgnoreCMABCache,
426+
decide.EnabledFlagsOnly,
427+
decide.ResetCMABCache,
428+
decide.InvalidateUserCMABCache,
429+
}
430+
convertedOptions = convertDecideOptions(options)
431+
assert.True(t, convertedOptions.DisableDecisionEvent)
432+
assert.True(t, convertedOptions.EnabledFlagsOnly)
433+
assert.True(t, convertedOptions.IgnoreCMABCache)
434+
assert.True(t, convertedOptions.ResetCMABCache)
435+
assert.True(t, convertedOptions.InvalidateUserCMABCache)
436+
}

pkg/config/datafileprojectconfig/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,15 @@ func (c DatafileProjectConfig) GetExperimentByKey(experimentKey string) (entitie
247247
return entities.Experiment{}, fmt.Errorf(`experiment with key "%s" not found`, experimentKey)
248248
}
249249

250+
// GetExperimentByID returns the experiment with the given ID
251+
func (c DatafileProjectConfig) GetExperimentByID(experimentID string) (entities.Experiment, error) {
252+
if experiment, ok := c.experimentMap[experimentID]; ok {
253+
return experiment, nil
254+
}
255+
256+
return entities.Experiment{}, fmt.Errorf(`experiment with ID "%s" not found`, experimentID)
257+
}
258+
250259
// GetGroupByID returns the group with the given ID
251260
func (c DatafileProjectConfig) GetGroupByID(groupID string) (entities.Group, error) {
252261
if group, ok := c.groupMap[groupID]; ok {

pkg/config/datafileprojectconfig/config_test.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ func TestCmabExperiments(t *testing.T) {
590590
experiments := datafileJSON["experiments"].([]interface{})
591591
exp0 := experiments[0].(map[string]interface{})
592592
exp0["cmab"] = map[string]interface{}{
593-
"attributes": []string{"808797688", "808797689"},
593+
"attributeIds": []string{"808797688", "808797689"},
594594
"trafficAllocation": 5000, // Changed from array to integer
595595
}
596596

@@ -655,6 +655,34 @@ func TestCmabExperimentsNil(t *testing.T) {
655655
}
656656
}
657657

658+
func TestGetExperimentByID(t *testing.T) {
659+
// Create a test config with some experiments
660+
testConfig := DatafileProjectConfig{
661+
experimentMap: map[string]entities.Experiment{
662+
"exp1": {ID: "exp1", Key: "experiment_1"},
663+
"exp2": {ID: "exp2", Key: "experiment_2"},
664+
},
665+
}
666+
667+
// Test getting an experiment that exists
668+
experiment, err := testConfig.GetExperimentByID("exp1")
669+
assert.NoError(t, err)
670+
assert.Equal(t, "exp1", experiment.ID)
671+
assert.Equal(t, "experiment_1", experiment.Key)
672+
673+
// Test getting another experiment that exists
674+
experiment, err = testConfig.GetExperimentByID("exp2")
675+
assert.NoError(t, err)
676+
assert.Equal(t, "exp2", experiment.ID)
677+
assert.Equal(t, "experiment_2", experiment.Key)
678+
679+
// Test getting an experiment that doesn't exist
680+
experiment, err = testConfig.GetExperimentByID("non_existent")
681+
assert.Error(t, err)
682+
assert.Equal(t, `experiment with ID "non_existent" not found`, err.Error())
683+
assert.Equal(t, entities.Experiment{}, experiment)
684+
}
685+
658686
func TestGetAttributeKeyByID(t *testing.T) {
659687
// Setup
660688
id := "id"

pkg/config/datafileprojectconfig/entities/entities.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type Attribute struct {
3636
// It contains a list of attribute IDs that are used for the CMAB algorithm and
3737
// traffic allocation settings for the CMAB implementation.
3838
type Cmab struct {
39-
AttributeIds []string `json:"attributes"`
39+
AttributeIds []string `json:"attributeIds"`
4040
TrafficAllocation int `json:"trafficAllocation"`
4141
}
4242

pkg/config/interface.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2019-2022, Optimizely, Inc. and contributors *
2+
* Copyright 2019-2025, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -31,6 +31,8 @@ type ProjectConfig interface {
3131
GetAnonymizeIP() bool
3232
GetAttributeID(id string) string // returns "" if there is no id
3333
GetAttributeByKey(key string) (entities.Attribute, error)
34+
GetAttributeKeyByID(id string) (string, error) // method is intended for internal use only
35+
GetExperimentByID(id string) (entities.Experiment, error) // method is intended for internal use only
3436
GetAudienceList() (audienceList []entities.Audience)
3537
GetAudienceByID(string) (entities.Audience, error)
3638
GetAudienceMap() map[string]entities.Audience

pkg/decide/decide_options.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2020-2021, Optimizely, Inc. and contributors *
2+
* Copyright 2020-2025, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -33,6 +33,12 @@ const (
3333
IncludeReasons OptimizelyDecideOptions = "INCLUDE_REASONS"
3434
// ExcludeVariables when set, excludes variable values from the decision result.
3535
ExcludeVariables OptimizelyDecideOptions = "EXCLUDE_VARIABLES"
36+
// IgnoreCMABCache instructs the SDK to ignore the CMAB cache and make a fresh request
37+
IgnoreCMABCache OptimizelyDecideOptions = "IGNORE_CMAB_CACHE"
38+
// ResetCMABCache instructs the SDK to reset the entire CMAB cache
39+
ResetCMABCache OptimizelyDecideOptions = "RESET_CMAB_CACHE"
40+
// InvalidateUserCMABCache instructs the SDK to invalidate CMAB cache entries for the current user
41+
InvalidateUserCMABCache OptimizelyDecideOptions = "INVALIDATE_USER_CMAB_CACHE"
3642
)
3743

3844
// Options defines options for controlling flag decisions.
@@ -42,6 +48,9 @@ type Options struct {
4248
IgnoreUserProfileService bool
4349
IncludeReasons bool
4450
ExcludeVariables bool
51+
IgnoreCMABCache bool
52+
ResetCMABCache bool
53+
InvalidateUserCMABCache bool
4554
}
4655

4756
// TranslateOptions converts string options array to array of OptimizelyDecideOptions
@@ -59,6 +68,12 @@ func TranslateOptions(options []string) ([]OptimizelyDecideOptions, error) {
5968
decideOptions = append(decideOptions, ExcludeVariables)
6069
case IncludeReasons:
6170
decideOptions = append(decideOptions, IncludeReasons)
71+
case IgnoreCMABCache:
72+
decideOptions = append(decideOptions, IgnoreCMABCache)
73+
case ResetCMABCache:
74+
decideOptions = append(decideOptions, ResetCMABCache)
75+
case InvalidateUserCMABCache:
76+
decideOptions = append(decideOptions, InvalidateUserCMABCache)
6277
default:
6378
return []OptimizelyDecideOptions{}, errors.New("invalid option: " + val)
6479
}

pkg/decide/decide_options_test.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2021, Optimizely, Inc. and contributors *
2+
* Copyright 2021-2025, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -68,3 +68,47 @@ func TestTranslateOptionsInvalidCases(t *testing.T) {
6868
assert.Equal(t, fmt.Errorf("invalid option: %v", options[0]), err)
6969
assert.Len(t, translatedOptions, 0)
7070
}
71+
72+
// TestTranslateOptionsCMABOptions tests the new CMAB-related options
73+
func TestTranslateOptionsCMABOptions(t *testing.T) {
74+
// Test IGNORE_CMAB_CACHE option
75+
options := []string{"IGNORE_CMAB_CACHE"}
76+
translatedOptions, err := TranslateOptions(options)
77+
assert.NoError(t, err)
78+
assert.Len(t, translatedOptions, 1)
79+
assert.Equal(t, IgnoreCMABCache, translatedOptions[0])
80+
81+
// Test RESET_CMAB_CACHE option
82+
options = []string{"RESET_CMAB_CACHE"}
83+
translatedOptions, err = TranslateOptions(options)
84+
assert.NoError(t, err)
85+
assert.Len(t, translatedOptions, 1)
86+
assert.Equal(t, ResetCMABCache, translatedOptions[0])
87+
88+
// Test INVALIDATE_USER_CMAB_CACHE option
89+
options = []string{"INVALIDATE_USER_CMAB_CACHE"}
90+
translatedOptions, err = TranslateOptions(options)
91+
assert.NoError(t, err)
92+
assert.Len(t, translatedOptions, 1)
93+
assert.Equal(t, InvalidateUserCMABCache, translatedOptions[0])
94+
95+
// Test all CMAB options together
96+
options = []string{"IGNORE_CMAB_CACHE", "RESET_CMAB_CACHE", "INVALIDATE_USER_CMAB_CACHE"}
97+
translatedOptions, err = TranslateOptions(options)
98+
assert.NoError(t, err)
99+
assert.Len(t, translatedOptions, 3)
100+
assert.Equal(t, IgnoreCMABCache, translatedOptions[0])
101+
assert.Equal(t, ResetCMABCache, translatedOptions[1])
102+
assert.Equal(t, InvalidateUserCMABCache, translatedOptions[2])
103+
104+
// Test CMAB options with other options
105+
options = []string{"DISABLE_DECISION_EVENT", "IGNORE_CMAB_CACHE", "ENABLED_FLAGS_ONLY", "RESET_CMAB_CACHE", "INVALIDATE_USER_CMAB_CACHE"}
106+
translatedOptions, err = TranslateOptions(options)
107+
assert.NoError(t, err)
108+
assert.Len(t, translatedOptions, 5)
109+
assert.Equal(t, DisableDecisionEvent, translatedOptions[0])
110+
assert.Equal(t, IgnoreCMABCache, translatedOptions[1])
111+
assert.Equal(t, EnabledFlagsOnly, translatedOptions[2])
112+
assert.Equal(t, ResetCMABCache, translatedOptions[3])
113+
assert.Equal(t, InvalidateUserCMABCache, translatedOptions[4])
114+
}

pkg/decision/cmab.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/****************************************************************************
2+
* Copyright 2025, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package decision provides CMAB decision service interfaces and types
18+
package decision
19+
20+
import (
21+
"github.com/optimizely/go-sdk/v2/pkg/config"
22+
"github.com/optimizely/go-sdk/v2/pkg/decide"
23+
"github.com/optimizely/go-sdk/v2/pkg/entities"
24+
)
25+
26+
// CmabDecision represents a decision from the CMAB service
27+
type CmabDecision struct {
28+
VariationID string
29+
CmabUUID string
30+
Reasons []string
31+
}
32+
33+
// CmabCacheValue represents a cached CMAB decision with attribute hash
34+
type CmabCacheValue struct {
35+
AttributesHash string
36+
VariationID string
37+
CmabUUID string
38+
}
39+
40+
// CmabService defines the interface for CMAB decision services
41+
type CmabService interface {
42+
// GetDecision returns a CMAB decision for the given rule and user context
43+
GetDecision(
44+
projectConfig config.ProjectConfig,
45+
userContext entities.UserContext,
46+
ruleID string,
47+
options *decide.Options,
48+
) (CmabDecision, error)
49+
}
50+
51+
// CmabClient defines the interface for CMAB API clients
52+
type CmabClient interface {
53+
// FetchDecision fetches a decision from the CMAB API
54+
FetchDecision(
55+
ruleID string,
56+
userID string,
57+
attributes map[string]interface{},
58+
cmabUUID string,
59+
) (string, error)
60+
}

0 commit comments

Comments
 (0)