Skip to content

Commit c3d9b16

Browse files
[FSSDK-12014] prediction endpoint addition (#395)
1 parent 1f405cd commit c3d9b16

File tree

7 files changed

+141
-20
lines changed

7 files changed

+141
-20
lines changed

OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ private static string ValidBody(string variationId = "v1")
9696
public void FetchDecisionReturnsSuccessNoRetry()
9797
{
9898
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v1")));
99-
var client = new DefaultCmabClient(http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler());
99+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler());
100100
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
101101

102102
Assert.AreEqual("v1", result);
@@ -106,7 +106,7 @@ public void FetchDecisionReturnsSuccessNoRetry()
106106
public void FetchDecisionHttpExceptionNoRetry()
107107
{
108108
var http = MakeClientExceptionSequence(new HttpRequestException("boom"));
109-
var client = new DefaultCmabClient(http, retryConfig: null);
109+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
110110

111111
Assert.Throws<CmabFetchException>(() =>
112112
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
@@ -116,7 +116,7 @@ public void FetchDecisionHttpExceptionNoRetry()
116116
public void FetchDecisionNon2xxNoRetry()
117117
{
118118
var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null));
119-
var client = new DefaultCmabClient(http, retryConfig: null);
119+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
120120

121121
Assert.Throws<CmabFetchException>(() =>
122122
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
@@ -126,7 +126,7 @@ public void FetchDecisionNon2xxNoRetry()
126126
public void FetchDecisionInvalidJsonNoRetry()
127127
{
128128
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json"));
129-
var client = new DefaultCmabClient(http, retryConfig: null);
129+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
130130

131131
Assert.Throws<CmabInvalidResponseException>(() =>
132132
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
@@ -136,7 +136,7 @@ public void FetchDecisionInvalidJsonNoRetry()
136136
public void FetchDecisionInvalidStructureNoRetry()
137137
{
138138
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}"));
139-
var client = new DefaultCmabClient(http, retryConfig: null);
139+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
140140

141141
Assert.Throws<CmabInvalidResponseException>(() =>
142142
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
@@ -147,7 +147,7 @@ public void FetchDecisionSuccessWithRetryFirstTry()
147147
{
148148
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v2")));
149149
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
150-
var client = new DefaultCmabClient(http, retry);
150+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
151151
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
152152

153153
Assert.AreEqual("v2", result);
@@ -162,7 +162,7 @@ public void FetchDecisionSuccessWithRetryThirdTry()
162162
new ResponseStep(HttpStatusCode.OK, ValidBody("v3"))
163163
);
164164
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
165-
var client = new DefaultCmabClient(http, retry);
165+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
166166
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
167167

168168
Assert.AreEqual("v3", result);
@@ -177,10 +177,40 @@ public void FetchDecisionExhaustsAllRetries()
177177
new ResponseStep(HttpStatusCode.InternalServerError, null)
178178
);
179179
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
180-
var client = new DefaultCmabClient(http, retry);
180+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
181181

182182
Assert.Throws<CmabFetchException>(() =>
183183
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
184184
}
185+
186+
[Test]
187+
public void FetchDecision_CustomEndpoint_CallsCorrectUrl()
188+
{
189+
var customEndpoint = "https://custom.example.com/api/{0}";
190+
string capturedUrl = null;
191+
192+
var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
193+
handler.Protected()
194+
.Setup<Task<HttpResponseMessage>>("SendAsync",
195+
ItExpr.IsAny<HttpRequestMessage>(),
196+
ItExpr.IsAny<CancellationToken>())
197+
.Returns((HttpRequestMessage req, CancellationToken _) =>
198+
{
199+
capturedUrl = req.RequestUri.ToString();
200+
var response = new HttpResponseMessage(HttpStatusCode.OK)
201+
{
202+
Content = new StringContent(ValidBody("variation123"))
203+
};
204+
return Task.FromResult(response);
205+
});
206+
207+
var http = new HttpClient(handler.Object);
208+
var client = new DefaultCmabClient(customEndpoint, http, retryConfig: null);
209+
var result = client.FetchDecision("rule-456", "user-1", null, "uuid-1");
210+
211+
Assert.AreEqual("variation123", result);
212+
Assert.AreEqual("https://custom.example.com/api/rule-456", capturedUrl,
213+
"Should call custom endpoint with rule ID formatted into template");
214+
}
185215
}
186216
}

OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings()
383383
{
384384
var cache = new LruCache<CmabCacheEntry>(CmabConstants.DEFAULT_CACHE_SIZE,
385385
CmabConstants.DEFAULT_CACHE_TTL, _logger);
386-
var client = new DefaultCmabClient(null,
386+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
387387
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
388388
var service = new DefaultCmabService(cache, client, _logger);
389389
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
@@ -397,7 +397,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings()
397397
public void ConstructorAppliesCustomCacheSize()
398398
{
399399
var cache = new LruCache<CmabCacheEntry>(42, CmabConstants.DEFAULT_CACHE_TTL, _logger);
400-
var client = new DefaultCmabClient(null,
400+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
401401
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
402402
var service = new DefaultCmabService(cache, client, _logger);
403403
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
@@ -413,7 +413,7 @@ public void ConstructorAppliesCustomCacheTtl()
413413
var expectedTtl = TimeSpan.FromMinutes(3);
414414
var cache = new LruCache<CmabCacheEntry>(CmabConstants.DEFAULT_CACHE_SIZE, expectedTtl,
415415
_logger);
416-
var client = new DefaultCmabClient(null,
416+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
417417
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
418418
var service = new DefaultCmabService(cache, client, _logger);
419419
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
@@ -428,7 +428,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl()
428428
{
429429
var expectedTtl = TimeSpan.FromSeconds(90);
430430
var cache = new LruCache<CmabCacheEntry>(5, expectedTtl, _logger);
431-
var client = new DefaultCmabClient(null,
431+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
432432
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
433433
var service = new DefaultCmabService(cache, client, _logger);
434434
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
@@ -442,7 +442,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl()
442442
public void ConstructorUsesProvidedCustomCacheInstance()
443443
{
444444
var customCache = new LruCache<CmabCacheEntry>(3, TimeSpan.FromSeconds(5), _logger);
445-
var client = new DefaultCmabClient(null,
445+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
446446
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
447447
var service = new DefaultCmabService(customCache, client, _logger);
448448
var cache = GetInternalCache(service);
@@ -455,7 +455,7 @@ public void ConstructorUsesProvidedCustomCacheInstance()
455455
public void ConstructorAcceptsAnyICacheImplementation()
456456
{
457457
var fakeCache = new FakeCache();
458-
var client = new DefaultCmabClient(null,
458+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
459459
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
460460
var service = new DefaultCmabService(fakeCache, client, _logger);
461461
var cache = GetInternalCache(service);
@@ -470,7 +470,7 @@ public void ConstructorCreatesDefaultClientWhenNoneProvided()
470470
{
471471
var cache = new LruCache<CmabCacheEntry>(CmabConstants.DEFAULT_CACHE_SIZE,
472472
CmabConstants.DEFAULT_CACHE_TTL, _logger);
473-
var client = new DefaultCmabClient(null,
473+
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
474474
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
475475
var service = new DefaultCmabService(cache, client, _logger);
476476
var internalClient = GetInternalClient(service);

OptimizelySDK.Tests/OptimizelyTest.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
using Moq;
2323
using NUnit.Framework;
2424
using OptimizelySDK.Bucketing;
25+
using OptimizelySDK.Cmab;
2526
using OptimizelySDK.Config;
2627
using OptimizelySDK.Entity;
2728
using OptimizelySDK.ErrorHandler;
@@ -6268,5 +6269,76 @@ public void TestConstructedOptimizelyWithDatafileShouldHaveOdpEnabledByDefault()
62686269
}
62696270

62706271
#endregion
6272+
6273+
#region Test Optimizely & CMAB
6274+
6275+
[Test]
6276+
public void TestInitializeCmabServiceWithCustomEndpointPropagatesCorrectly()
6277+
{
6278+
var customEndpoint = "https://custom.example.com/predict/{0}";
6279+
var cmabConfig = new CmabConfig().SetPredictionEndpointTemplate(customEndpoint);
6280+
var configManager = new Mock<ProjectConfigManager>();
6281+
var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object);
6282+
configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig);
6283+
6284+
var optimizely = new Optimizely(
6285+
configManager: configManager.Object,
6286+
notificationCenter: null,
6287+
eventDispatcher: EventDispatcherMock.Object,
6288+
logger: LoggerMock.Object,
6289+
errorHandler: ErrorHandlerMock.Object,
6290+
userProfileService: null,
6291+
eventProcessor: null,
6292+
defaultDecideOptions: null,
6293+
odpManager: null,
6294+
cmabConfig: cmabConfig
6295+
);
6296+
6297+
var decisionService = optimizely.GetType()
6298+
.GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely);
6299+
var cmabService = decisionService?.GetType()
6300+
.GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService);
6301+
var client = cmabService?.GetType()
6302+
.GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService);
6303+
var actualEndpoint = client?.GetType()
6304+
.GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string;
6305+
6306+
Assert.AreEqual(customEndpoint, actualEndpoint, "Custom endpoint should propagate to CMAB client");
6307+
}
6308+
6309+
[Test]
6310+
public void TestInitializeCmabServiceWithoutCustomEndpointUsesDefault()
6311+
{
6312+
var configManager = new Mock<ProjectConfigManager>();
6313+
var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object);
6314+
configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig);
6315+
6316+
var optimizely = new Optimizely(
6317+
configManager: configManager.Object,
6318+
notificationCenter: null,
6319+
eventDispatcher: EventDispatcherMock.Object,
6320+
logger: LoggerMock.Object,
6321+
errorHandler: ErrorHandlerMock.Object,
6322+
userProfileService: null,
6323+
eventProcessor: null,
6324+
defaultDecideOptions: null,
6325+
odpManager: null,
6326+
cmabConfig: null
6327+
);
6328+
6329+
var decisionService = optimizely.GetType()
6330+
.GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely);
6331+
var cmabService = decisionService?.GetType()
6332+
.GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService);
6333+
var client = cmabService?.GetType()
6334+
.GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService);
6335+
var actualEndpoint = client?.GetType()
6336+
.GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string;
6337+
6338+
Assert.AreEqual(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE,
6339+
actualEndpoint, "Should use default endpoint when no config provided");
6340+
}
6341+
6342+
#endregion
62716343
}
62726344
}

OptimizelySDK/Cmab/CmabConfig.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ public class CmabConfig
4343
/// </summary>
4444
public ICacheWithRemove<CmabCacheEntry> Cache { get; private set; }
4545

46+
/// <summary>
47+
/// Gets or sets the prediction endpoint URL template for CMAB requests.
48+
/// </summary>
49+
public string PredictionEndpointTemplate { get; private set; } = CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE;
50+
4651
/// <summary>
4752
/// Sets the maximum number of entries in the CMAB cache.
4853
/// </summary>
@@ -58,7 +63,7 @@ public CmabConfig SetCacheSize(int cacheSize)
5863
/// Sets the time-to-live for CMAB cache entries.
5964
/// </summary>
6065
/// <param name="cacheTtl">Time-to-live for cache entries.</param>
61-
/// <returns>This CmabConfig instance for method chaining.</returns>
66+
/// <returns>CmabConfig instance</returns>
6267
public CmabConfig SetCacheTtl(TimeSpan cacheTtl)
6368
{
6469
CacheTtl = cacheTtl;
@@ -70,11 +75,22 @@ public CmabConfig SetCacheTtl(TimeSpan cacheTtl)
7075
/// When set, CacheSize and CacheTtl will be ignored.
7176
/// </summary>
7277
/// <param name="cache">Custom cache implementation for CMAB decisions.</param>
73-
/// <returns>This CmabConfig instance for method chaining.</returns>
78+
/// <returns>CmabConfig Instance</returns>
7479
public CmabConfig SetCache(ICacheWithRemove<CmabCacheEntry> cache)
7580
{
7681
Cache = cache ?? throw new ArgumentNullException(nameof(cache));
7782
return this;
7883
}
84+
85+
/// <summary>
86+
/// Sets the prediction endpoint URL template for CMAB requests.
87+
/// </summary>
88+
/// <param name="template">The URL template</param>
89+
/// <returns>CmabConfig Instance</returns>
90+
public CmabConfig SetPredictionEndpointTemplate(string template)
91+
{
92+
PredictionEndpointTemplate = template;
93+
return this;
94+
}
7995
}
8096
}

OptimizelySDK/Cmab/CmabConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ namespace OptimizelySDK.Cmab
2020
{
2121
internal static class CmabConstants
2222
{
23-
public const string PREDICTION_URL = "https://prediction.cmab.optimizely.com/predict";
23+
public const string DEFAULT_PREDICTION_URL_TEMPLATE = "https://prediction.cmab.optimizely.com/predict/{0}";
2424
public const int DEFAULT_CACHE_SIZE = 10_000;
2525
public const string CONTENT_TYPE = "application/json";
2626

OptimizelySDK/Cmab/DefaultCmabClient.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,16 @@ public class DefaultCmabClient : ICmabClient
3838
private readonly CmabRetryConfig _retryConfig;
3939
private readonly ILogger _logger;
4040
private readonly IErrorHandler _errorHandler;
41+
private readonly string _predictionEndpointTemplate;
4142

4243
public DefaultCmabClient(
44+
string predictionEndpointTemplate,
4345
HttpClient httpClient = null,
4446
CmabRetryConfig retryConfig = null,
4547
ILogger logger = null,
4648
IErrorHandler errorHandler = null)
4749
{
50+
_predictionEndpointTemplate = predictionEndpointTemplate;
4851
_httpClient = httpClient ?? new HttpClient();
4952
_retryConfig = retryConfig;
5053
_logger = logger ?? new NoOpLogger();
@@ -58,7 +61,7 @@ private async Task<string> FetchDecisionAsync(
5861
string cmabUuid,
5962
TimeSpan? timeout = null)
6063
{
61-
var url = $"{CmabConstants.PREDICTION_URL}/{ruleId}";
64+
var url = string.Format(_predictionEndpointTemplate, ruleId);
6265
var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid);
6366
var perAttemptTimeout = timeout ?? CmabConstants.MAX_TIMEOUT;
6467

OptimizelySDK/Optimizely.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ private void InitializeComponents(IEventDispatcher eventDispatcher = null,
299299

300300
var cmabRetryConfig = new CmabRetryConfig(CmabConstants.CMAB_MAX_RETRIES,
301301
CmabConstants.CMAB_INITIAL_BACKOFF);
302-
var cmabClient = new DefaultCmabClient(null, cmabRetryConfig, Logger);
302+
var cmabClient = new DefaultCmabClient(config.PredictionEndpointTemplate, null, cmabRetryConfig, Logger, null);
303303

304304
cmabService = new DefaultCmabService(cache, cmabClient, Logger);
305305

0 commit comments

Comments
 (0)