Skip to content

Commit d5236fb

Browse files
authored
[FSSDK-12012] expose prediction endpoint (#427)
* expose prediction endpoint * Fix NewDefaultConfig to set PredictionEndpoint default value * Rename PredictionEndpoint to PredictionEndpointTemplate - Renamed field from PredictionEndpoint to PredictionEndpointTemplate for clarity - Updated all references across client, config, factory, and tests - Removed redundant constant definition from config.go - Removed setting predictionEndpoint in NewExperimentCmabService when config is nil - Changed test endpoint from endpoint.com to example.com (avoid real domain) - All tests passing * Format: align struct fields in client_test.go
1 parent 7306c8d commit d5236fb

File tree

7 files changed

+161
-80
lines changed

7 files changed

+161
-80
lines changed

pkg/client/factory.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ import (
4141
// CmabConfig holds CMAB configuration options exposed at the client level.
4242
// This provides a stable public API while allowing internal cmab.Config to change.
4343
type CmabConfig struct {
44-
CacheSize int
45-
CacheTTL time.Duration
46-
HTTPTimeout time.Duration
47-
Cache cache.CacheWithRemove // Custom cache implementation (Redis, etc.)
44+
CacheSize int
45+
CacheTTL time.Duration
46+
HTTPTimeout time.Duration
47+
Cache cache.CacheWithRemove // Custom cache implementation (Redis, etc.)
48+
PredictionEndpointTemplate string // Custom prediction endpoint template
4849
}
4950

5051
// toCmabConfig converts client-level CmabConfig to internal cmab.Config
@@ -53,10 +54,11 @@ func (c *CmabConfig) toCmabConfig() *cmab.Config {
5354
return nil
5455
}
5556
return &cmab.Config{
56-
CacheSize: c.CacheSize,
57-
CacheTTL: c.CacheTTL,
58-
HTTPTimeout: c.HTTPTimeout,
59-
Cache: c.Cache,
57+
CacheSize: c.CacheSize,
58+
CacheTTL: c.CacheTTL,
59+
HTTPTimeout: c.HTTPTimeout,
60+
Cache: c.Cache,
61+
PredictionEndpointTemplate: c.PredictionEndpointTemplate,
6062
}
6163
}
6264

pkg/client/factory_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,3 +560,46 @@ func TestClientWithEmptyCmabConfig(t *testing.T) {
560560
assert.NotNil(t, client.DecisionService)
561561
client.Close()
562562
}
563+
564+
func TestCmabConfigWithCustomPredictionEndpoint(t *testing.T) {
565+
// Test that custom prediction endpoint is correctly set in CmabConfig
566+
customEndpoint := "https://custom.example.com/predict/%s"
567+
cmabConfig := CmabConfig{
568+
CacheSize: 100,
569+
CacheTTL: time.Minute,
570+
HTTPTimeout: 30 * time.Second,
571+
PredictionEndpointTemplate: customEndpoint,
572+
}
573+
574+
factory := OptimizelyFactory{}
575+
WithCmabConfig(&cmabConfig)(&factory)
576+
577+
assert.Equal(t, &cmabConfig, factory.cmabConfig)
578+
assert.Equal(t, customEndpoint, factory.cmabConfig.PredictionEndpointTemplate)
579+
}
580+
581+
func TestCmabConfigToCmabConfig(t *testing.T) {
582+
// Test the toCmabConfig conversion includes PredictionEndpointTemplate
583+
customEndpoint := "https://proxy.example.com/cmab/%s"
584+
clientConfig := CmabConfig{
585+
CacheSize: 200,
586+
CacheTTL: 5 * time.Minute,
587+
HTTPTimeout: 15 * time.Second,
588+
PredictionEndpointTemplate: customEndpoint,
589+
}
590+
591+
internalConfig := clientConfig.toCmabConfig()
592+
593+
assert.NotNil(t, internalConfig)
594+
assert.Equal(t, 200, internalConfig.CacheSize)
595+
assert.Equal(t, 5*time.Minute, internalConfig.CacheTTL)
596+
assert.Equal(t, 15*time.Second, internalConfig.HTTPTimeout)
597+
assert.Equal(t, customEndpoint, internalConfig.PredictionEndpointTemplate)
598+
}
599+
600+
func TestCmabConfigToCmabConfigNil(t *testing.T) {
601+
// Test that nil CmabConfig returns nil
602+
var clientConfig *CmabConfig
603+
internalConfig := clientConfig.toCmabConfig()
604+
assert.Nil(t, internalConfig)
605+
}

pkg/cmab/client.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,9 @@ import (
3030
"github.com/optimizely/go-sdk/v2/pkg/logging"
3131
)
3232

33-
// CMABPredictionEndpoint is the endpoint for CMAB predictions
34-
var CMABPredictionEndpoint = "https://prediction.cmab.optimizely.com/predict/%s"
35-
3633
const (
34+
// DefaultPredictionEndpointTemplate is the default endpoint template for CMAB predictions
35+
DefaultPredictionEndpointTemplate = "https://prediction.cmab.optimizely.com/predict/%s"
3736
// DefaultMaxRetries is the default number of retries for CMAB requests
3837
DefaultMaxRetries = 1
3938
// DefaultInitialBackoff is the default initial backoff duration
@@ -88,16 +87,18 @@ type RetryConfig struct {
8887

8988
// DefaultCmabClient implements the CmabClient interface
9089
type DefaultCmabClient struct {
91-
httpClient *http.Client
92-
retryConfig *RetryConfig
93-
logger logging.OptimizelyLogProducer
90+
httpClient *http.Client
91+
retryConfig *RetryConfig
92+
logger logging.OptimizelyLogProducer
93+
predictionEndpoint string
9494
}
9595

9696
// ClientOptions defines options for creating a CMAB client
9797
type ClientOptions struct {
98-
HTTPClient *http.Client
99-
RetryConfig *RetryConfig
100-
Logger logging.OptimizelyLogProducer
98+
HTTPClient *http.Client
99+
RetryConfig *RetryConfig
100+
Logger logging.OptimizelyLogProducer
101+
PredictionEndpointTemplate string
101102
}
102103

103104
// NewDefaultCmabClient creates a new instance of DefaultCmabClient
@@ -118,10 +119,17 @@ func NewDefaultCmabClient(options ClientOptions) *DefaultCmabClient {
118119
logger = logging.GetLogger("", "DefaultCmabClient")
119120
}
120121

122+
// Use custom endpoint or default
123+
predictionEndpoint := options.PredictionEndpointTemplate
124+
if predictionEndpoint == "" {
125+
predictionEndpoint = DefaultPredictionEndpointTemplate
126+
}
127+
121128
return &DefaultCmabClient{
122-
httpClient: httpClient,
123-
retryConfig: retryConfig,
124-
logger: logger,
129+
httpClient: httpClient,
130+
retryConfig: retryConfig,
131+
logger: logger,
132+
predictionEndpoint: predictionEndpoint,
125133
}
126134
}
127135

@@ -134,7 +142,7 @@ func (c *DefaultCmabClient) FetchDecision(
134142
) (string, error) {
135143

136144
// Create the URL
137-
url := fmt.Sprintf(CMABPredictionEndpoint, ruleID)
145+
url := fmt.Sprintf(c.predictionEndpoint, ruleID)
138146

139147
// Log the URL being called
140148
c.logger.Debug(fmt.Sprintf("CMAB Prediction URL: %s", url))

pkg/cmab/client_test.go

Lines changed: 66 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,9 @@ func TestDefaultCmabClient_FetchDecision(t *testing.T) {
124124
HTTPClient: &http.Client{
125125
Timeout: 5 * time.Second,
126126
},
127+
PredictionEndpointTemplate: server.URL + "/%s",
127128
})
128129

129-
// Override the endpoint for testing
130-
originalEndpoint := CMABPredictionEndpoint
131-
CMABPredictionEndpoint = server.URL + "/%s"
132-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
133-
134130
// Test with various attribute types
135131
attributes := map[string]interface{}{
136132
"string_attr": "string value",
@@ -205,13 +201,9 @@ func TestDefaultCmabClient_FetchDecision_WithRetry(t *testing.T) {
205201
MaxBackoff: 100 * time.Millisecond,
206202
BackoffMultiplier: 2.0,
207203
},
204+
PredictionEndpointTemplate: server.URL + "/%s",
208205
})
209206

210-
// Override the endpoint for testing
211-
originalEndpoint := CMABPredictionEndpoint
212-
CMABPredictionEndpoint = server.URL + "/%s"
213-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
214-
215207
// Test fetch decision with retry
216208
attributes := map[string]interface{}{
217209
"browser": "chrome",
@@ -253,13 +245,9 @@ func TestDefaultCmabClient_FetchDecision_ExhaustedRetries(t *testing.T) {
253245
MaxBackoff: 100 * time.Millisecond,
254246
BackoffMultiplier: 2.0,
255247
},
248+
PredictionEndpointTemplate: server.URL + "/%s",
256249
})
257250

258-
// Override the endpoint for testing
259-
originalEndpoint := CMABPredictionEndpoint
260-
CMABPredictionEndpoint = server.URL + "/%s"
261-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
262-
263251
// Test fetch decision with exhausted retries
264252
attributes := map[string]interface{}{
265253
"browser": "chrome",
@@ -300,14 +288,10 @@ func TestDefaultCmabClient_FetchDecision_NoRetryConfig(t *testing.T) {
300288
HTTPClient: &http.Client{
301289
Timeout: 5 * time.Second,
302290
},
303-
RetryConfig: nil, // Explicitly set to nil to override default
291+
RetryConfig: nil, // Explicitly set to nil to override default
292+
PredictionEndpointTemplate: server.URL + "/%s",
304293
})
305294

306-
// Override the endpoint for testing
307-
originalEndpoint := CMABPredictionEndpoint
308-
CMABPredictionEndpoint = server.URL + "/%s"
309-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
310-
311295
// Test fetch decision without retry config
312296
attributes := map[string]interface{}{
313297
"browser": "chrome",
@@ -364,13 +348,9 @@ func TestDefaultCmabClient_FetchDecision_InvalidResponse(t *testing.T) {
364348
HTTPClient: &http.Client{
365349
Timeout: 5 * time.Second,
366350
},
351+
PredictionEndpointTemplate: server.URL + "/%s",
367352
})
368353

369-
// Override the endpoint for testing
370-
originalEndpoint := CMABPredictionEndpoint
371-
CMABPredictionEndpoint = server.URL + "/%s"
372-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
373-
374354
// Test fetch decision with invalid response
375355
attributes := map[string]interface{}{
376356
"browser": "chrome",
@@ -407,14 +387,10 @@ func TestDefaultCmabClient_FetchDecision_NetworkErrors(t *testing.T) {
407387
MaxBackoff: 100 * time.Millisecond,
408388
BackoffMultiplier: 2.0,
409389
},
410-
Logger: mockLogger,
390+
Logger: mockLogger,
391+
PredictionEndpointTemplate: "http://non-existent-server.example.com/%s",
411392
})
412393

413-
// Set endpoint to a non-existent server
414-
originalEndpoint := CMABPredictionEndpoint
415-
CMABPredictionEndpoint = "http://non-existent-server.example.com/%s"
416-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
417-
418394
// Test fetch decision with network error
419395
attributes := map[string]interface{}{
420396
"browser": "chrome",
@@ -468,13 +444,9 @@ func TestDefaultCmabClient_ExponentialBackoff(t *testing.T) {
468444
MaxBackoff: 1 * time.Second,
469445
BackoffMultiplier: 2.0,
470446
},
447+
PredictionEndpointTemplate: server.URL + "/%s",
471448
})
472449

473-
// Override the endpoint for testing
474-
originalEndpoint := CMABPredictionEndpoint
475-
CMABPredictionEndpoint = server.URL + "/%s"
476-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
477-
478450
// Test fetch decision with exponential backoff
479451
attributes := map[string]interface{}{
480452
"browser": "chrome",
@@ -555,14 +527,10 @@ func TestDefaultCmabClient_LoggingBehavior(t *testing.T) {
555527
MaxBackoff: 100 * time.Millisecond,
556528
BackoffMultiplier: 2.0,
557529
},
558-
Logger: mockLogger,
530+
Logger: mockLogger,
531+
PredictionEndpointTemplate: server.URL + "/%s",
559532
})
560533

561-
// Override the endpoint for testing
562-
originalEndpoint := CMABPredictionEndpoint
563-
CMABPredictionEndpoint = server.URL + "/%s"
564-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
565-
566534
// Test fetch decision
567535
attributes := map[string]interface{}{
568536
"browser": "chrome",
@@ -618,14 +586,10 @@ func TestDefaultCmabClient_NonSuccessStatusCode(t *testing.T) {
618586
HTTPClient: &http.Client{
619587
Timeout: 5 * time.Second,
620588
},
589+
PredictionEndpointTemplate: server.URL + "/%s",
621590
// No retry config to simplify the test
622591
})
623592

624-
// Override the endpoint for testing
625-
originalEndpoint := CMABPredictionEndpoint
626-
CMABPredictionEndpoint = server.URL + "/%s"
627-
defer func() { CMABPredictionEndpoint = originalEndpoint }()
628-
629593
// Test fetch decision
630594
attributes := map[string]interface{}{
631595
"browser": "chrome",
@@ -641,3 +605,57 @@ func TestDefaultCmabClient_NonSuccessStatusCode(t *testing.T) {
641605
})
642606
}
643607
}
608+
func TestDefaultCmabClient_CustomPredictionEndpoint(t *testing.T) {
609+
// Setup test server
610+
customEndpointCalled := false
611+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
612+
customEndpointCalled = true
613+
// Verify the URL path contains the rule ID
614+
assert.Contains(t, r.URL.Path, "rule456")
615+
616+
// Return a valid response
617+
response := Response{
618+
Predictions: []Prediction{
619+
{VariationID: "variation789"},
620+
},
621+
}
622+
json.NewEncoder(w).Encode(response)
623+
}))
624+
defer server.Close()
625+
626+
// Create client with custom prediction endpoint
627+
customEndpoint := server.URL + "/custom/predict/%s"
628+
client := NewDefaultCmabClient(ClientOptions{
629+
PredictionEndpointTemplate: customEndpoint,
630+
})
631+
632+
// Test fetch decision
633+
attributes := map[string]interface{}{
634+
"age": 25,
635+
}
636+
637+
variationID, err := client.FetchDecision("rule456", "user123", attributes, "test-uuid")
638+
639+
// Verify results
640+
assert.NoError(t, err)
641+
assert.Equal(t, "variation789", variationID)
642+
assert.True(t, customEndpointCalled, "Custom endpoint should have been called")
643+
}
644+
645+
func TestDefaultCmabClient_DefaultPredictionEndpointTemplate(t *testing.T) {
646+
// Create client without specifying prediction endpoint
647+
client := NewDefaultCmabClient(ClientOptions{})
648+
649+
// Verify it uses the default endpoint
650+
assert.Equal(t, DefaultPredictionEndpointTemplate, client.predictionEndpoint)
651+
}
652+
653+
func TestDefaultCmabClient_EmptyPredictionEndpointUsesDefault(t *testing.T) {
654+
// Create client with empty prediction endpoint
655+
client := NewDefaultCmabClient(ClientOptions{
656+
PredictionEndpointTemplate: "",
657+
})
658+
659+
// Verify it uses the default endpoint when empty string is provided
660+
assert.Equal(t, DefaultPredictionEndpointTemplate, client.predictionEndpoint)
661+
}

pkg/cmab/config.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,21 @@ const (
3636

3737
// Config holds CMAB configuration options
3838
type Config struct {
39-
CacheSize int
40-
CacheTTL time.Duration
41-
HTTPTimeout time.Duration
42-
RetryConfig *RetryConfig
43-
Cache cache.CacheWithRemove // Custom cache implementation (Redis, etc.)
39+
CacheSize int
40+
CacheTTL time.Duration
41+
HTTPTimeout time.Duration
42+
RetryConfig *RetryConfig
43+
Cache cache.CacheWithRemove // Custom cache implementation (Redis, etc.)
44+
PredictionEndpointTemplate string // Custom prediction endpoint template
4445
}
4546

4647
// NewDefaultConfig creates a Config with default values
4748
func NewDefaultConfig() Config {
4849
return Config{
49-
CacheSize: DefaultCacheSize,
50-
CacheTTL: DefaultCacheTTL,
51-
HTTPTimeout: DefaultHTTPTimeout,
50+
CacheSize: DefaultCacheSize,
51+
CacheTTL: DefaultCacheTTL,
52+
HTTPTimeout: DefaultHTTPTimeout,
53+
PredictionEndpointTemplate: DefaultPredictionEndpointTemplate,
5254
RetryConfig: &RetryConfig{
5355
MaxRetries: DefaultMaxRetries,
5456
},

pkg/cmab/config_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func TestNewDefaultConfig(t *testing.T) {
2828
assert.Equal(t, DefaultCacheSize, config.CacheSize)
2929
assert.Equal(t, DefaultCacheTTL, config.CacheTTL)
3030
assert.Equal(t, DefaultHTTPTimeout, config.HTTPTimeout)
31+
assert.Equal(t, DefaultPredictionEndpointTemplate, config.PredictionEndpointTemplate)
3132
assert.NotNil(t, config.RetryConfig)
3233
assert.Equal(t, DefaultMaxRetries, config.RetryConfig.MaxRetries)
3334
assert.Nil(t, config.Cache) // Should be nil by default

0 commit comments

Comments
 (0)