Skip to content
Merged
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
6 changes: 3 additions & 3 deletions lib/optimizely.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 9 additions & 2 deletions lib/optimizely/cmab/cmab_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = {
Expand Down
7 changes: 5 additions & 2 deletions lib/optimizely/helpers/sdk_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand All @@ -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],
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion lib/optimizely/optimizely_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
54 changes: 52 additions & 2 deletions spec/cmab/cmab_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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