diff --git a/lib/optimizely.rb b/lib/optimizely.rb index 1894ed18..c64ea794 100644 --- a/lib/optimizely.rb +++ b/lib/optimizely.rb @@ -137,9 +137,9 @@ def initialize( # Initialize CMAB components if cmab service is nil if cmab_service.nil? @cmab_client = DefaultCmabClient.new( - nil, - CmabRetryConfig.new, - @logger + http_client: nil, + retry_config: CmabRetryConfig.new, + logger: @logger ) @cmab_cache = LRUCache.new(Optimizely::DefaultCmabCacheOptions::DEFAULT_CMAB_CACHE_SIZE, Optimizely::DefaultCmabCacheOptions::DEFAULT_CMAB_CACHE_TIMEOUT) @cmab_service = DefaultCmabService.new( diff --git a/lib/optimizely/cmab/cmab_client.rb b/lib/optimizely/cmab/cmab_client.rb index 8b6b7fa1..229eb11a 100644 --- a/lib/optimizely/cmab/cmab_client.rb +++ b/lib/optimizely/cmab/cmab_client.rb @@ -43,15 +43,22 @@ class DefaultCmabClient # Client for interacting with the CMAB service. # Provides methods to fetch decisions with optional retry logic. - def initialize(http_client = nil, retry_config = nil, logger = nil) + def initialize(http_client: nil, retry_config: nil, logger: nil, prediction_endpoint: nil) # Initialize the CMAB client. # Args: # http_client: HTTP client for making requests. # retry_config: Configuration for retry settings. # logger: Logger for logging errors and info. + # prediction_endpoint: Custom prediction endpoint URL template. + # Use #{rule_id} as placeholder for rule_id. @http_client = http_client || DefaultHttpClient.new @retry_config = retry_config || CmabRetryConfig.new @logger = logger || NoOpLogger.new + @prediction_endpoint = if prediction_endpoint.to_s.strip.empty? + 'https://prediction.cmab.optimizely.com/predict/%s' + else + prediction_endpoint + end end def fetch_decision(rule_id, user_id, attributes, cmab_uuid, timeout: MAX_WAIT_TIME) @@ -64,7 +71,7 @@ def fetch_decision(rule_id, user_id, attributes, cmab_uuid, timeout: MAX_WAIT_TI # timeout: Maximum wait time for the request to respond in seconds. (default is 10 seconds). # Returns: # The variation ID. - url = "https://prediction.cmab.optimizely.com/predict/#{rule_id}" + url = format(@prediction_endpoint, rule_id) cmab_attributes = attributes.map { |key, value| {'id' => key.to_s, 'value' => value, 'type' => 'custom_attribute'} } request_body = { diff --git a/lib/optimizely/helpers/sdk_settings.rb b/lib/optimizely/helpers/sdk_settings.rb index 3ca2dc72..6ccc82f8 100644 --- a/lib/optimizely/helpers/sdk_settings.rb +++ b/lib/optimizely/helpers/sdk_settings.rb @@ -22,7 +22,7 @@ module Optimizely module Helpers class OptimizelySdkSettings attr_accessor :odp_disabled, :segments_cache_size, :segments_cache_timeout_in_secs, :odp_segments_cache, :odp_segment_manager, - :odp_event_manager, :fetch_segments_timeout, :odp_event_timeout, :odp_flush_interval + :odp_event_manager, :fetch_segments_timeout, :odp_event_timeout, :odp_flush_interval, :cmab_prediction_endpoint # Contains configuration used for Optimizely Project initialization. # @@ -35,6 +35,7 @@ class OptimizelySdkSettings # @param odp_segment_request_timeout - Time to wait in seconds for fetch_qualified_segments (optional. default = 10). # @param odp_event_request_timeout - Time to wait in seconds for send_odp_events (optional. default = 10). # @param odp_event_flush_interval - Time to wait in seconds for odp events to accumulate before sending (optional. default = 1). + # @param cmab_prediction_endpoint - Custom CMAB prediction endpoint URL template (optional). Use %s as placeholder for rule_id. Defaults to production endpoint if not provided. def initialize( disable_odp: false, segments_cache_size: Constants::ODP_SEGMENTS_CACHE_CONFIG[:DEFAULT_CAPACITY], @@ -44,7 +45,8 @@ def initialize( odp_event_manager: nil, odp_segment_request_timeout: nil, odp_event_request_timeout: nil, - odp_event_flush_interval: nil + odp_event_flush_interval: nil, + cmab_prediction_endpoint: nil ) @odp_disabled = disable_odp @segments_cache_size = segments_cache_size @@ -55,6 +57,7 @@ def initialize( @fetch_segments_timeout = odp_segment_request_timeout @odp_event_timeout = odp_event_request_timeout @odp_flush_interval = odp_event_flush_interval + @cmab_prediction_endpoint = cmab_prediction_endpoint end end end diff --git a/lib/optimizely/optimizely_factory.rb b/lib/optimizely/optimizely_factory.rb index f2d5ad72..dbf1410b 100644 --- a/lib/optimizely/optimizely_factory.rb +++ b/lib/optimizely/optimizely_factory.rb @@ -119,6 +119,12 @@ def self.cmab_custom_cache(custom_cache) @cmab_custom_cache = custom_cache end + # Convenience method for setting custom CMAB prediction endpoint. + # @param prediction_endpoint String - Custom URL template for CMAB prediction API. Use %s as placeholder for rule_id. + def self.cmab_prediction_endpoint(prediction_endpoint) + @cmab_prediction_endpoint = prediction_endpoint + end + # Returns a new optimizely instance. # # @params sdk_key - Required String uniquely identifying the fallback datafile corresponding to project. @@ -202,7 +208,11 @@ def self.custom_instance( # rubocop:disable Metrics/ParameterLists ) # Initialize CMAB components - cmab_client = DefaultCmabClient.new(logger: logger) + cmab_prediction_endpoint = nil + cmab_prediction_endpoint = settings.cmab_prediction_endpoint if settings&.cmab_prediction_endpoint + cmab_prediction_endpoint ||= @cmab_prediction_endpoint + + cmab_client = DefaultCmabClient.new(logger: logger, prediction_endpoint: cmab_prediction_endpoint) cmab_cache = @cmab_custom_cache || LRUCache.new( @cmab_cache_size || Optimizely::DefaultCmabCacheOptions::DEFAULT_CMAB_CACHE_SIZE, @cmab_cache_ttl || Optimizely::DefaultCmabCacheOptions::DEFAULT_CMAB_CACHE_TIMEOUT diff --git a/spec/cmab/cmab_client_spec.rb b/spec/cmab/cmab_client_spec.rb index daa9ccd4..ae348fab 100644 --- a/spec/cmab/cmab_client_spec.rb +++ b/spec/cmab/cmab_client_spec.rb @@ -55,7 +55,7 @@ end context 'when client is configured without retries' do - let(:client) { described_class.new(nil, Optimizely::CmabRetryConfig.new(max_retries: 0), spy_logger) } + let(:client) { described_class.new(http_client: nil, retry_config: Optimizely::CmabRetryConfig.new(max_retries: 0), logger: spy_logger) } it 'should return the variation id on success' do WebMock.stub_request(:post, expected_url) @@ -132,7 +132,7 @@ end context 'when client is configured with retries' do - let(:client_with_retry) { described_class.new(nil, retry_config, spy_logger) } + let(:client_with_retry) { described_class.new(http_client: nil, retry_config: retry_config, logger: spy_logger) } it 'should return the variation id on first try' do WebMock.stub_request(:post, expected_url) @@ -195,4 +195,54 @@ expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Max retries exceeded for CMAB request')) end end + + context 'when custom prediction endpoint is configured' do + let(:custom_endpoint) { 'https://custom.endpoint.com/predict/%s' } + let(:custom_url) { 'https://custom.endpoint.com/predict/test_rule' } + let(:client_with_custom_endpoint) { described_class.new(http_client: nil, retry_config: Optimizely::CmabRetryConfig.new(max_retries: 0), logger: spy_logger, prediction_endpoint: custom_endpoint) } + + it 'should use the custom prediction endpoint' do + WebMock.stub_request(:post, custom_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 200, body: {'predictions' => [{'variation_id' => 'custom123'}]}.to_json, headers: {'Content-Type' => 'application/json'}) + + result = client_with_custom_endpoint.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + + expect(result).to eq('custom123') + expect(WebMock).to have_requested(:post, custom_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + end + end + + context 'when no prediction endpoint is provided' do + let(:client_with_default) { described_class.new(http_client: nil, retry_config: Optimizely::CmabRetryConfig.new(max_retries: 0), logger: spy_logger, prediction_endpoint: nil) } + + it 'should use the default prediction endpoint' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 200, body: {'predictions' => [{'variation_id' => 'default123'}]}.to_json, headers: {'Content-Type' => 'application/json'}) + + result = client_with_default.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + + expect(result).to eq('default123') + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + end + end + + context 'when empty string prediction endpoint is provided' do + let(:client_with_empty_endpoint) { described_class.new(http_client: nil, retry_config: Optimizely::CmabRetryConfig.new(max_retries: 0), logger: spy_logger, prediction_endpoint: '') } + + it 'should fall back to the default prediction endpoint' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 200, body: {'predictions' => [{'variation_id' => 'fallback123'}]}.to_json, headers: {'Content-Type' => 'application/json'}) + + result = client_with_empty_endpoint.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + + expect(result).to eq('fallback123') + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + end + end end