diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs index 87a80e33..3ff1de96 100644 --- a/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs @@ -96,7 +96,7 @@ private static string ValidBody(string variationId = "v1") public void FetchDecisionReturnsSuccessNoRetry() { var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v1"))); - var client = new DefaultCmabClient(http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler()); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler()); var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); Assert.AreEqual("v1", result); @@ -106,7 +106,7 @@ public void FetchDecisionReturnsSuccessNoRetry() public void FetchDecisionHttpExceptionNoRetry() { var http = MakeClientExceptionSequence(new HttpRequestException("boom")); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -116,7 +116,7 @@ public void FetchDecisionHttpExceptionNoRetry() public void FetchDecisionNon2xxNoRetry() { var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null)); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -126,7 +126,7 @@ public void FetchDecisionNon2xxNoRetry() public void FetchDecisionInvalidJsonNoRetry() { var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json")); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -136,7 +136,7 @@ public void FetchDecisionInvalidJsonNoRetry() public void FetchDecisionInvalidStructureNoRetry() { var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}")); - var client = new DefaultCmabClient(http, retryConfig: null); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); @@ -147,7 +147,7 @@ public void FetchDecisionSuccessWithRetryFirstTry() { var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v2"))); var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0); - var client = new DefaultCmabClient(http, retry); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry); var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); Assert.AreEqual("v2", result); @@ -162,7 +162,7 @@ public void FetchDecisionSuccessWithRetryThirdTry() new ResponseStep(HttpStatusCode.OK, ValidBody("v3")) ); var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0); - var client = new DefaultCmabClient(http, retry); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry); var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1"); Assert.AreEqual("v3", result); @@ -177,10 +177,40 @@ public void FetchDecisionExhaustsAllRetries() new ResponseStep(HttpStatusCode.InternalServerError, null) ); var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0); - var client = new DefaultCmabClient(http, retry); + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry); Assert.Throws(() => client.FetchDecision("rule-1", "user-1", null, "uuid-1")); } + + [Test] + public void FetchDecision_CustomEndpoint_CallsCorrectUrl() + { + var customEndpoint = "https://custom.example.com/api/{0}"; + string capturedUrl = null; + + var handler = new Mock(MockBehavior.Strict); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((HttpRequestMessage req, CancellationToken _) => + { + capturedUrl = req.RequestUri.ToString(); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ValidBody("variation123")) + }; + return Task.FromResult(response); + }); + + var http = new HttpClient(handler.Object); + var client = new DefaultCmabClient(customEndpoint, http, retryConfig: null); + var result = client.FetchDecision("rule-456", "user-1", null, "uuid-1"); + + Assert.AreEqual("variation123", result); + Assert.AreEqual("https://custom.example.com/api/rule-456", capturedUrl, + "Should call custom endpoint with rule ID formatted into template"); + } } } diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs index 5c891a9d..2e101f26 100644 --- a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs @@ -383,7 +383,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings() { var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, CmabConstants.DEFAULT_CACHE_TTL, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -397,7 +397,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings() public void ConstructorAppliesCustomCacheSize() { var cache = new LruCache(42, CmabConstants.DEFAULT_CACHE_TTL, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -413,7 +413,7 @@ public void ConstructorAppliesCustomCacheTtl() var expectedTtl = TimeSpan.FromMinutes(3); var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, expectedTtl, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -428,7 +428,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl() { var expectedTtl = TimeSpan.FromSeconds(90); var cache = new LruCache(5, expectedTtl, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalCache = GetInternalCache(service) as LruCache; @@ -442,7 +442,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl() public void ConstructorUsesProvidedCustomCacheInstance() { var customCache = new LruCache(3, TimeSpan.FromSeconds(5), _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(customCache, client, _logger); var cache = GetInternalCache(service); @@ -455,7 +455,7 @@ public void ConstructorUsesProvidedCustomCacheInstance() public void ConstructorAcceptsAnyICacheImplementation() { var fakeCache = new FakeCache(); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(fakeCache, client, _logger); var cache = GetInternalCache(service); @@ -470,7 +470,7 @@ public void ConstructorCreatesDefaultClientWhenNoneProvided() { var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, CmabConstants.DEFAULT_CACHE_TTL, _logger); - var client = new DefaultCmabClient(null, + var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null, new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger); var service = new DefaultCmabService(cache, client, _logger); var internalClient = GetInternalClient(service); diff --git a/OptimizelySDK.Tests/OptimizelyTest.cs b/OptimizelySDK.Tests/OptimizelyTest.cs index 3025dc89..26052021 100644 --- a/OptimizelySDK.Tests/OptimizelyTest.cs +++ b/OptimizelySDK.Tests/OptimizelyTest.cs @@ -22,6 +22,7 @@ using Moq; using NUnit.Framework; using OptimizelySDK.Bucketing; +using OptimizelySDK.Cmab; using OptimizelySDK.Config; using OptimizelySDK.Entity; using OptimizelySDK.ErrorHandler; @@ -6268,5 +6269,76 @@ public void TestConstructedOptimizelyWithDatafileShouldHaveOdpEnabledByDefault() } #endregion + + #region Test Optimizely & CMAB + + [Test] + public void TestInitializeCmabServiceWithCustomEndpointPropagatesCorrectly() + { + var customEndpoint = "https://custom.example.com/predict/{0}"; + var cmabConfig = new CmabConfig().SetPredictionEndpointTemplate(customEndpoint); + var configManager = new Mock(); + var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object); + configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig); + + var optimizely = new Optimizely( + configManager: configManager.Object, + notificationCenter: null, + eventDispatcher: EventDispatcherMock.Object, + logger: LoggerMock.Object, + errorHandler: ErrorHandlerMock.Object, + userProfileService: null, + eventProcessor: null, + defaultDecideOptions: null, + odpManager: null, + cmabConfig: cmabConfig + ); + + var decisionService = optimizely.GetType() + .GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely); + var cmabService = decisionService?.GetType() + .GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService); + var client = cmabService?.GetType() + .GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService); + var actualEndpoint = client?.GetType() + .GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string; + + Assert.AreEqual(customEndpoint, actualEndpoint, "Custom endpoint should propagate to CMAB client"); + } + + [Test] + public void TestInitializeCmabServiceWithoutCustomEndpointUsesDefault() + { + var configManager = new Mock(); + var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object); + configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig); + + var optimizely = new Optimizely( + configManager: configManager.Object, + notificationCenter: null, + eventDispatcher: EventDispatcherMock.Object, + logger: LoggerMock.Object, + errorHandler: ErrorHandlerMock.Object, + userProfileService: null, + eventProcessor: null, + defaultDecideOptions: null, + odpManager: null, + cmabConfig: null + ); + + var decisionService = optimizely.GetType() + .GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely); + var cmabService = decisionService?.GetType() + .GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService); + var client = cmabService?.GetType() + .GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService); + var actualEndpoint = client?.GetType() + .GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string; + + Assert.AreEqual(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, + actualEndpoint, "Should use default endpoint when no config provided"); + } + + #endregion } } diff --git a/OptimizelySDK/Cmab/CmabConfig.cs b/OptimizelySDK/Cmab/CmabConfig.cs index 55b7fc11..ae7f9f12 100644 --- a/OptimizelySDK/Cmab/CmabConfig.cs +++ b/OptimizelySDK/Cmab/CmabConfig.cs @@ -43,6 +43,11 @@ public class CmabConfig /// public ICacheWithRemove Cache { get; private set; } + /// + /// Gets or sets the prediction endpoint URL template for CMAB requests. + /// + public string PredictionEndpointTemplate { get; private set; } = CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE; + /// /// Sets the maximum number of entries in the CMAB cache. /// @@ -58,7 +63,7 @@ public CmabConfig SetCacheSize(int cacheSize) /// Sets the time-to-live for CMAB cache entries. /// /// Time-to-live for cache entries. - /// This CmabConfig instance for method chaining. + /// CmabConfig instance public CmabConfig SetCacheTtl(TimeSpan cacheTtl) { CacheTtl = cacheTtl; @@ -70,11 +75,22 @@ public CmabConfig SetCacheTtl(TimeSpan cacheTtl) /// When set, CacheSize and CacheTtl will be ignored. /// /// Custom cache implementation for CMAB decisions. - /// This CmabConfig instance for method chaining. + /// CmabConfig Instance public CmabConfig SetCache(ICacheWithRemove cache) { Cache = cache ?? throw new ArgumentNullException(nameof(cache)); return this; } + + /// + /// Sets the prediction endpoint URL template for CMAB requests. + /// + /// The URL template + /// CmabConfig Instance + public CmabConfig SetPredictionEndpointTemplate(string template) + { + PredictionEndpointTemplate = template; + return this; + } } } diff --git a/OptimizelySDK/Cmab/CmabConstants.cs b/OptimizelySDK/Cmab/CmabConstants.cs index 0b7525a3..c30f6de2 100644 --- a/OptimizelySDK/Cmab/CmabConstants.cs +++ b/OptimizelySDK/Cmab/CmabConstants.cs @@ -20,7 +20,7 @@ namespace OptimizelySDK.Cmab { internal static class CmabConstants { - public const string PREDICTION_URL = "https://prediction.cmab.optimizely.com/predict"; + public const string DEFAULT_PREDICTION_URL_TEMPLATE = "https://prediction.cmab.optimizely.com/predict/{0}"; public const int DEFAULT_CACHE_SIZE = 10_000; public const string CONTENT_TYPE = "application/json"; diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs index a06f2149..c76341f9 100644 --- a/OptimizelySDK/Cmab/DefaultCmabClient.cs +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -38,13 +38,16 @@ public class DefaultCmabClient : ICmabClient private readonly CmabRetryConfig _retryConfig; private readonly ILogger _logger; private readonly IErrorHandler _errorHandler; + private readonly string _predictionEndpointTemplate; public DefaultCmabClient( + string predictionEndpointTemplate, HttpClient httpClient = null, CmabRetryConfig retryConfig = null, ILogger logger = null, IErrorHandler errorHandler = null) { + _predictionEndpointTemplate = predictionEndpointTemplate; _httpClient = httpClient ?? new HttpClient(); _retryConfig = retryConfig; _logger = logger ?? new NoOpLogger(); @@ -58,7 +61,7 @@ private async Task FetchDecisionAsync( string cmabUuid, TimeSpan? timeout = null) { - var url = $"{CmabConstants.PREDICTION_URL}/{ruleId}"; + var url = string.Format(_predictionEndpointTemplate, ruleId); var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid); var perAttemptTimeout = timeout ?? CmabConstants.MAX_TIMEOUT; diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index a27e228b..ed4f7469 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -299,7 +299,7 @@ private void InitializeComponents(IEventDispatcher eventDispatcher = null, var cmabRetryConfig = new CmabRetryConfig(CmabConstants.CMAB_MAX_RETRIES, CmabConstants.CMAB_INITIAL_BACKOFF); - var cmabClient = new DefaultCmabClient(null, cmabRetryConfig, Logger); + var cmabClient = new DefaultCmabClient(config.PredictionEndpointTemplate, null, cmabRetryConfig, Logger, null); cmabService = new DefaultCmabService(cache, cmabClient, Logger);