Skip to content
Closed
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
961 changes: 961 additions & 0 deletions FINAL_GO_SDK_CODE_SAMPLES.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/optimizely/go-sdk/v2

go 1.21.0
go 1.21

require (
github.com/google/uuid v1.3.0
Expand Down
12 changes: 11 additions & 1 deletion pkg/cmab/service_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2025, Optimizely, Inc. and contributors *
* Copyright 2025-2026, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
Expand Down Expand Up @@ -235,6 +235,16 @@ func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Hol
return args.Get(0).([]entities.Holdout)
}

func (m *MockProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout {
args := m.Called(ruleID)
return args.Get(0).([]entities.Holdout)
}

func (m *MockProjectConfig) GetGlobalHoldouts() []entities.Holdout {
args := m.Called()
return args.Get(0).([]entities.Holdout)
}

type CmabServiceTestSuite struct {
suite.Suite
mockClient *MockCmabClient
Expand Down
25 changes: 23 additions & 2 deletions pkg/config/datafileprojectconfig/config.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2019-2025, Optimizely, Inc. and contributors *
* Copyright 2019-2026, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
Expand Down Expand Up @@ -63,6 +63,7 @@ type DatafileProjectConfig struct {
holdouts []entities.Holdout
holdoutIDMap map[string]entities.Holdout
flagHoldoutsMap map[string][]entities.Holdout
ruleHoldoutsMap map[string][]entities.Holdout
}

// GetDatafile returns a string representation of the environment's datafile
Expand Down Expand Up @@ -292,6 +293,25 @@ func (c DatafileProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.
return []entities.Holdout{}
}

// GetHoldoutsForRule returns local holdouts targeting a specific rule
func (c DatafileProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout {
if holdouts, exists := c.ruleHoldoutsMap[ruleID]; exists {
return holdouts
}
return []entities.Holdout{}
}

// GetGlobalHoldouts returns all global holdouts (applies to all rules)
func (c DatafileProjectConfig) GetGlobalHoldouts() []entities.Holdout {
globalHoldouts := []entities.Holdout{}
for _, holdout := range c.holdouts {
if holdout.IsGlobal() {
globalHoldouts = append(globalHoldouts, holdout)
}
}
return globalHoldouts
}

// NewDatafileProjectConfig initializes a new datafile from a json byte array using the default JSON datafile parser
func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogProducer) (*DatafileProjectConfig, error) {
datafile, err := Parse(jsonDatafile)
Expand Down Expand Up @@ -338,7 +358,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP

audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...))
flagVariationsMap := mappers.MapFlagVariations(featureMap)
holdouts, holdoutIDMap, flagHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap)
holdouts, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap)

attributeKeyMap := make(map[string]entities.Attribute)
attributeIDToKeyMap := make(map[string]string)
Expand Down Expand Up @@ -384,6 +404,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
holdouts: holdouts,
holdoutIDMap: holdoutIDMap,
flagHoldoutsMap: flagHoldoutsMap,
ruleHoldoutsMap: ruleHoldoutsMap,
}

logger.Info("Datafile is valid.")
Expand Down
92 changes: 92 additions & 0 deletions pkg/config/datafileprojectconfig/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -789,3 +789,95 @@ func TestGetHoldoutsForFlagWithDifferentFlag(t *testing.T) {
assert.Len(t, actual, 0)
assert.Equal(t, []entities.Holdout{}, actual)
}

func TestGetHoldoutsForRuleWithHoldouts(t *testing.T) {
ruleID := "rule_123"
holdout1 := entities.Holdout{
ID: "holdout_1",
Key: "test_holdout_1",
Status: entities.HoldoutStatusRunning,
IncludedRules: []string{ruleID},
}
holdout2 := entities.Holdout{
ID: "holdout_2",
Key: "test_holdout_2",
Status: entities.HoldoutStatusRunning,
IncludedRules: []string{ruleID},
}

ruleHoldoutsMap := make(map[string][]entities.Holdout)
ruleHoldoutsMap[ruleID] = []entities.Holdout{holdout1, holdout2}

config := &DatafileProjectConfig{
ruleHoldoutsMap: ruleHoldoutsMap,
}

actual := config.GetHoldoutsForRule(ruleID)
assert.Len(t, actual, 2)
assert.Equal(t, holdout1, actual[0])
assert.Equal(t, holdout2, actual[1])
}

func TestGetHoldoutsForRuleWithNoHoldouts(t *testing.T) {
ruleID := "rule_123"
config := &DatafileProjectConfig{
ruleHoldoutsMap: make(map[string][]entities.Holdout),
}

actual := config.GetHoldoutsForRule(ruleID)
assert.Len(t, actual, 0)
assert.Equal(t, []entities.Holdout{}, actual)
}

func TestGetGlobalHoldouts(t *testing.T) {
globalHoldout1 := entities.Holdout{
ID: "global_holdout_1",
Key: "test_global_holdout_1",
Status: entities.HoldoutStatusRunning,
IncludedRules: nil, // nil = global
}
globalHoldout2 := entities.Holdout{
ID: "global_holdout_2",
Key: "test_global_holdout_2",
Status: entities.HoldoutStatusRunning,
IncludedRules: nil, // nil = global
}
localHoldout := entities.Holdout{
ID: "local_holdout",
Key: "test_local_holdout",
Status: entities.HoldoutStatusRunning,
IncludedRules: []string{"rule_123"}, // non-nil = local
}

config := &DatafileProjectConfig{
holdouts: []entities.Holdout{globalHoldout1, localHoldout, globalHoldout2},
}

actual := config.GetGlobalHoldouts()
assert.Len(t, actual, 2)
assert.Equal(t, globalHoldout1, actual[0])
assert.Equal(t, globalHoldout2, actual[1])
}

func TestGetGlobalHoldoutsWithNoGlobal(t *testing.T) {
localHoldout1 := entities.Holdout{
ID: "local_holdout_1",
Key: "test_local_holdout_1",
Status: entities.HoldoutStatusRunning,
IncludedRules: []string{"rule_123"},
}
localHoldout2 := entities.Holdout{
ID: "local_holdout_2",
Key: "test_local_holdout_2",
Status: entities.HoldoutStatusRunning,
IncludedRules: []string{"rule_456"},
}

config := &DatafileProjectConfig{
holdouts: []entities.Holdout{localHoldout1, localHoldout2},
}

actual := config.GetGlobalHoldouts()
assert.Len(t, actual, 0)
assert.Equal(t, []entities.Holdout{}, actual)
}
3 changes: 1 addition & 2 deletions pkg/config/datafileprojectconfig/entities/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,7 @@ type Holdout struct {
AudienceConditions interface{} `json:"audienceConditions"`
Variations []Variation `json:"variations"`
TrafficAllocation []TrafficAllocation `json:"trafficAllocation"`
IncludedFlags []string `json:"includedFlags,omitempty"`
ExcludedFlags []string `json:"excludedFlags,omitempty"`
IncludedRules []string `json:"includedRules,omitempty"`
}

// Integration represents a integration from the Optimizely datafile
Expand Down
56 changes: 23 additions & 33 deletions pkg/config/datafileprojectconfig/mappers/holdout.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2025, Optimizely, Inc. and contributors *
* Copyright 2025-2026, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
Expand All @@ -23,19 +23,19 @@ import (
)

// MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities
// and organizes them by flag relationships
// and organizes them by rule relationships
func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]entities.Feature) (
holdoutList []entities.Holdout,
holdoutIDMap map[string]entities.Holdout,
flagHoldoutsMap map[string][]entities.Holdout,
ruleHoldoutsMap map[string][]entities.Holdout,
) {
holdoutList = []entities.Holdout{}
holdoutIDMap = make(map[string]entities.Holdout)
flagHoldoutsMap = make(map[string][]entities.Holdout)
ruleHoldoutsMap = make(map[string][]entities.Holdout)

globalHoldouts := []entities.Holdout{}
includedHoldouts := make(map[string][]entities.Holdout)
excludedHoldouts := make(map[string][]entities.Holdout)

for _, holdout := range holdouts {
// Only process running holdouts
Expand All @@ -47,46 +47,28 @@ func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]enti
holdoutList = append(holdoutList, mappedHoldout)
holdoutIDMap[holdout.ID] = mappedHoldout

// Classify holdout by flag relationships
if len(holdout.IncludedFlags) == 0 {
// Global holdout - applies to all flags except excluded
// Classify holdout by scope
if len(holdout.IncludedRules) == 0 {
// Global holdout - applies to all rules (IncludedRules is nil or empty)
globalHoldouts = append(globalHoldouts, mappedHoldout)

// Track exclusions
for _, flagID := range holdout.ExcludedFlags {
excludedHoldouts[flagID] = append(excludedHoldouts[flagID], mappedHoldout)
}
} else {
// Specific holdout - applies only to included flags
for _, flagID := range holdout.IncludedFlags {
includedHoldouts[flagID] = append(includedHoldouts[flagID], mappedHoldout)
// Local holdout - applies only to specific rules
for _, ruleID := range holdout.IncludedRules {
ruleHoldoutsMap[ruleID] = append(ruleHoldoutsMap[ruleID], mappedHoldout)
}
}
}

// Build flagHoldoutsMap by combining global and specific holdouts
// Global holdouts take precedence (evaluated first), then specific holdouts
// Build flagHoldoutsMap with only global holdouts (for backward compatibility)
// Global holdouts are evaluated at flag level (before any rules)
for _, feature := range featureMap {
flagKey := feature.Key
flagID := feature.ID
applicableHoldouts := []entities.Holdout{}

// Add global holdouts first (if not excluded) - they take precedence
if _, exists := excludedHoldouts[flagID]; !exists {
applicableHoldouts = append(applicableHoldouts, globalHoldouts...)
}

// Add specifically included holdouts second
if included, exists := includedHoldouts[flagID]; exists {
applicableHoldouts = append(applicableHoldouts, included...)
}

if len(applicableHoldouts) > 0 {
flagHoldoutsMap[flagKey] = applicableHoldouts
if len(globalHoldouts) > 0 {
flagHoldoutsMap[flagKey] = globalHoldouts
}
}

return holdoutList, holdoutIDMap, flagHoldoutsMap
return holdoutList, holdoutIDMap, flagHoldoutsMap, ruleHoldoutsMap
}

func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout {
Expand Down Expand Up @@ -130,6 +112,13 @@ func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout {
}
}

// Map IncludedRules - nil if not present (global), otherwise copy the slice
var includedRules []string
if len(datafileHoldout.IncludedRules) > 0 {
includedRules = make([]string, len(datafileHoldout.IncludedRules))
copy(includedRules, datafileHoldout.IncludedRules)
}

return entities.Holdout{
ID: datafileHoldout.ID,
Key: datafileHoldout.Key,
Expand All @@ -139,5 +128,6 @@ func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout {
Variations: variations,
TrafficAllocation: trafficAllocation,
AudienceConditionTree: audienceConditionTree,
IncludedRules: includedRules,
}
}
Loading
Loading