kameleoon-client-ruby 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,25 +14,29 @@ module Kameleoon
14
14
 
15
15
  # Rule is a class for new rules of feature flags
16
16
  class Rule
17
- attr_accessor :type, :exposition, :experiment_id, :variation_by_exposition, :targeting_segment
17
+ attr_reader :id, :order, :type, :exposition, :experiment_id, :variation_by_exposition, :respool_time
18
+ attr_accessor :targeting_segment
18
19
 
19
20
  def self.create_from_array(array)
20
21
  array&.map { |it| Rule.new(it) }
21
22
  end
22
23
 
23
24
  def initialize(hash)
25
+ @id = hash['id']
26
+ @order = hash['order']
24
27
  @type = hash['type']
25
28
  @exposition = hash['exposition']
26
29
  @experiment_id = hash['experimentId']
30
+ @respool_time = hash['respoolTime']
27
31
  @variation_by_exposition = VariationByExposition.create_from_array(hash['variationByExposition'])
28
32
  @targeting_segment = Kameleoon::Targeting::Segment.new((hash['segment'])) if hash['segment']
29
33
  end
30
34
 
31
- def get_variation_key(hash_double)
35
+ def get_variation(hash_double)
32
36
  total = 0.0
33
- variation_by_exposition.each do |element|
34
- total += element.exposition
35
- return element.variation_key if total >= hash_double
37
+ variation_by_exposition.each do |var_by_exp|
38
+ total += var_by_exp.exposition
39
+ return var_by_exp if total >= hash_double
36
40
  end
37
41
  nil
38
42
  end
@@ -40,6 +44,14 @@ module Kameleoon
40
44
  def get_variation_id_by_key(key)
41
45
  variation_by_exposition.select { |v| v.variation_key == key }.first&.variation_id
42
46
  end
47
+
48
+ def experiment_type?
49
+ @type == RuleType::EXPERIMENTATION
50
+ end
51
+
52
+ def targeted_delivery_type?
53
+ @type == RuleType::TARGETED_DELIVERY
54
+ end
43
55
  end
44
56
  end
45
57
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kameleoon
4
+ # Module which contains all internal data of SDK
5
+ module Configuration
6
+ # KameleoonConfigurationSettings is used for saving setting's parameters, e.g
7
+ # state of real time update for site code and etc
8
+ class Settings
9
+ attr_accessor :real_time_update
10
+
11
+ def initialize
12
+ @real_time_update = false
13
+ end
14
+
15
+ def update(configuration)
16
+ @real_time_update = configuration['realTimeUpdate'] || false
17
+ end
18
+ end
19
+ end
20
+ end
@@ -26,7 +26,7 @@ module Kameleoon
26
26
  obtain_hash_double_helper(visitor_code, respool_times, container_id, '')
27
27
  end
28
28
 
29
- def obtain_hash_double_v2(visitor_code, container_id = '', suffix = '')
29
+ def obtain_hash_double_rule(visitor_code, container_id = '', suffix = '')
30
30
  obtain_hash_double_helper(visitor_code, {}, container_id, suffix)
31
31
  end
32
32
 
@@ -37,8 +37,8 @@ module Kameleoon
37
37
  identifier += respool_times.sort.to_h.values.join.to_s if !respool_times.nil? && !respool_times.empty?
38
38
  (Digest::SHA256.hexdigest(identifier.encode('UTF-8')).to_i(16) / (BigDecimal('2')**BigDecimal('256'))).round(16)
39
39
  end
40
-
41
- def check_visitor_code(visitor_code)
40
+
41
+ def check_visitor_code(visitor_code)
42
42
  if visitor_code.nil?
43
43
  check_default_visitor_code('')
44
44
  elsif
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'kameleoon/exceptions'
4
+
3
5
  module Kameleoon
4
6
  NONCE_LENGTH = 16
5
7
 
@@ -50,38 +52,42 @@ module Kameleoon
50
52
  end
51
53
 
52
54
  class CustomData < Data
53
- attr_accessor :id, :value
55
+ attr_reader :id, :values
54
56
 
55
57
  # @param [Integer] id Id of the custom data
56
58
  # @param [String] value Value of the custom data
57
59
  #
58
60
  # @overload
59
61
  # @param [Hash] hash Json value encoded in a hash.
60
- def initialize(*args)
62
+ def initialize(arg0, *args)
61
63
  @instance = DataType::CUSTOM
62
64
  @sent = false
63
- unless args.empty?
64
- if args.length == 1
65
- hash = args.first
66
- if hash["id"].nil?
67
- raise NotFoundError.new("id")
68
- end
69
- @id = hash["id"].to_s
70
- if hash["value"].nil?
71
- raise NotFoundError.new(hash["value"])
72
- end
73
- @value = hash["value"]
74
- elsif args.length == 2
75
- @id = args[0].to_s
76
- @value = args[1]
65
+ if arg0.is_a?(Hash)
66
+ hash = arg0
67
+ id = hash['id']
68
+ raise Kameleoon::Exception::NotFound.new('id') if id.nil?
69
+ @id = id.to_s
70
+ value = hash['value']
71
+ values = hash['values']
72
+ raise Kameleoon::Exception::NotFound.new('values') if values.nil? && value.nil?
73
+ if values.nil?
74
+ @values = [value]
75
+ else
76
+ @values = values.dup
77
+ @values.append(value) unless value.nil?
77
78
  end
79
+ else
80
+ @id = arg0.to_s
81
+ @values = args
78
82
  end
79
83
  end
80
84
 
81
85
  def obtain_full_post_text_line
82
- to_encode = "[[\"" + @value.to_s.gsub("\"", "\\\"") + "\",1]]"
86
+ return '' if @values.empty?
87
+
88
+ str_values = "[[\"#{@values.join('",1],["')}\",1]]"
83
89
  nonce = Kameleoon::Utils.generate_random_string(NONCE_LENGTH)
84
- "eventType=customData&index=" + @id.to_s + "&valueToCount=" + encode(to_encode) + "&overwrite=true&nonce=" + nonce
90
+ "eventType=customData&index=#{@id}&valueToCount=#{encode(str_values)}&overwrite=true&nonce=#{nonce}"
85
91
  end
86
92
  end
87
93
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kameleoon
4
+ module Hybrid
5
+ TC_INIT = 'window.kameleoonQueue=window.kameleoonQueue||[];'
6
+ TC_ASSIGN_VARIATION_FORMAT = "window.kameleoonQueue.push(['Experiments.assignVariation',%d,%d]);"
7
+ TC_TRIGGER_FORMAT = "window.kameleoonQueue.push(['Experiments.trigger',%d,true]);"
8
+ TC_ASSIGN_VARIATION_TRIGGER_FORMAT = TC_ASSIGN_VARIATION_FORMAT + TC_TRIGGER_FORMAT
9
+
10
+ # Will be useful for Ruby 3.0
11
+ # Abstract Manager class (interface)
12
+ class Manager
13
+ def add_variation(_visitor, _experiment_id, _variation_id)
14
+ raise 'Abstract method `add` called'
15
+ end
16
+
17
+ def get_engine_tracking_code(_visitor)
18
+ raise 'Abstract method `get_engine_tracking_code` called'
19
+ end
20
+ end
21
+
22
+ # Implementation of Cache with auto cleaning feature
23
+ class ManagerImpl < Manager
24
+ def initialize(expiration_time, cleaning_interval, cache_factory, log_func)
25
+ super()
26
+ # synchronization is necessary for adding same visitor_code from different threads
27
+ @mutex = Mutex.new
28
+ @expiration_time = expiration_time
29
+ @cache_factory = cache_factory
30
+ @log = log_func
31
+ # it's recommend to use cleaning_interval 3-4 times more than experiation_time for more performance
32
+ # in this case on every cleaning iteration storage will be cleaned 2/3 - 3/4 of volume
33
+ @cache = cache_factory.create(expiration_time, cleaning_interval)
34
+ @log.call('Hybrid Manager was successfully initialized')
35
+ end
36
+
37
+ def add_variation(visitor_code, experiment_id, variation_id)
38
+ @mutex.synchronize do
39
+ visitor_cache = @cache.get(visitor_code)
40
+ visitor_cache = @cache_factory.create(@expiration_time, 0) if visitor_cache.nil?
41
+ visitor_cache.set(experiment_id, variation_id)
42
+ @cache.set(visitor_code, visitor_cache)
43
+ end
44
+ @log.call("Hybrid Manager successfully added variation for visitor_code: #{visitor_code}, " \
45
+ "experiment: #{experiment_id}, variation: #{variation_id}")
46
+ end
47
+
48
+ def get_engine_tracking_code(visitor_code)
49
+ tracking_code = TC_INIT
50
+ visitor_cache = @cache.get(visitor_code)
51
+ return tracking_code if visitor_cache.nil?
52
+
53
+ visitor_cache.active_items.each_pair do |key, value|
54
+ tracking_code += format(TC_ASSIGN_VARIATION_TRIGGER_FORMAT, key, value, key)
55
+ end
56
+ tracking_code
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kameleoon Real Time Configuration Service
4
+
5
+ require 'json'
6
+ require 'kameleoon/real_time/real_time_event'
7
+ require 'kameleoon/real_time/sse_request'
8
+ require 'kameleoon/real_time/sse_client'
9
+
10
+ module Kameleoon
11
+ module RealTime
12
+ CONFIGURATION_UPDATE_EVENT = 'configuration-update-event'
13
+
14
+ ##
15
+ # RealTimeConfigurationService is used for fetching updates of configuration
16
+ # (experiments and feature flags) in real time.
17
+ class RealTimeConfigurationService
18
+ ##
19
+ # Parametrized initializer.
20
+ #
21
+ # @param url [String]
22
+ # @param update_handler [Callable[Kameleoon::RealTime::RealTimeEvent] | NilClass] Handler which
23
+ # is synchronously called for gotten RealTimeEvent objects.
24
+ # @param log_func [Callable[String] | NilClass] Callable object which synchronously called to log.
25
+ def initialize(url, update_handler, log_func, sse_request_source = nil)
26
+ @url = url
27
+ @update_handler = update_handler
28
+ @need_close = false
29
+ @headers = {
30
+ 'Accept': 'text/event-stream',
31
+ 'Cache-Control': 'no-cache',
32
+ 'Connection': 'Keep-Alive'
33
+ }
34
+ @log_func = log_func
35
+ @sse_request_source = sse_request_source
36
+ @sse_thread = nil
37
+ @sse_client = nil
38
+ create_sse_client
39
+ end
40
+
41
+ ##
42
+ # Closes the connection to the server.
43
+ def close
44
+ return if @need_close
45
+
46
+ @log_func&.call('Real-time configuration service is shutting down')
47
+ @need_close = true
48
+ return if @sse_thread.nil?
49
+
50
+ @sse_thread.kill
51
+ @sse_thread = nil
52
+ @sse_client.call_close_handler
53
+ end
54
+
55
+ private
56
+
57
+ def create_sse_client
58
+ @sse_thread = Thread.new { run_sse_client } unless @need_close
59
+ end
60
+
61
+ def init_sse_client
62
+ message_handler = proc do |message|
63
+ @log_func&.call("Got SSE event: #{message.event}")
64
+ if message.event == CONFIGURATION_UPDATE_EVENT
65
+ event_dict = JSON.parse(message.data)
66
+ @update_handler&.call(RealTimeEvent.new(event_dict))
67
+ end
68
+ end
69
+ sse_request = make_sse_request
70
+ @sse_client = SseClient.new(sse_request, message_handler)
71
+ @log_func&.call('Created SSE client')
72
+ end
73
+
74
+ def make_sse_request
75
+ open_handler = proc { @log_func&.call('SSE connection open') }
76
+ close_handler = proc { @log_func&.call('SSE connection closed') }
77
+ unexpected_status_code_handler =
78
+ proc { |resp| @log_func&.call("Unexpected status code of SSE response: #{resp.code}") }
79
+ if @sse_request_source.nil?
80
+ return SseRequest.new(@url, @headers, open_handler, close_handler, unexpected_status_code_handler)
81
+ end
82
+
83
+ @sse_request_source.call(@url, @headers, open_handler, close_handler, unexpected_status_code_handler)
84
+ end
85
+
86
+ def run_sse_client
87
+ until @need_close
88
+ init_sse_client
89
+ begin
90
+ @sse_client.start
91
+ rescue StandardError => e
92
+ @log_func&.call("Error occurred within SSE client: #{e}")
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kameleoon Real Time Event
4
+
5
+ module Kameleoon
6
+ module RealTime
7
+ ##
8
+ # RealTimeEvent contains information about timestamp when configuration was updated.
9
+ # Timestamp parameter is used to fetch the latest configuration.
10
+ class RealTimeEvent
11
+ attr_reader :time_stamp
12
+
13
+ ##
14
+ # Parametrized initializer. Gets the new time stamp from a JSON message.
15
+ #
16
+ # @param event_dict [Hash] Hash of SSE message data.
17
+ def initialize(event_dict)
18
+ @time_stamp = event_dict['ts']
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kameleoon/real_time/sse_message'
4
+
5
+ module Kameleoon
6
+ module RealTime
7
+ ##
8
+ # SseClient is used to interpret SSE event stream.
9
+ class SseClient
10
+ ##
11
+ # Parametrized initializer.
12
+ #
13
+ # @param sse_request [Kameleoon::RealTime::SseRequest] Used to access SSE event stream.
14
+ # @param message_handler [Callable[Kameleoon::RealTime::SseMessage] | NilClass] Callable object which
15
+ # is synchronously called for received SSE messages.
16
+ def initialize(sse_request, message_handler)
17
+ @sse_request = sse_request
18
+ @message_handler = message_handler
19
+
20
+ @cr_prev = nil
21
+ @buffer = nil
22
+ @data_buffer = nil
23
+ @event = nil
24
+ @id = nil
25
+ @reconnection_time = nil
26
+
27
+ @sse_request.resp_char_handler = method(:process_char)
28
+ end
29
+
30
+ ##
31
+ # Starts SSE connection and stay in the loop until close.
32
+ def start
33
+ @cr_prev = false
34
+ @data_buffer = []
35
+ @event = nil
36
+ @id = nil
37
+ @buffer = []
38
+ @sse_request.start
39
+ end
40
+
41
+ ##
42
+ # Calls @sse_request@close_handler if it is not nil.
43
+ def call_close_handler
44
+ @sse_request.call_close_handler
45
+ end
46
+
47
+ private
48
+
49
+ def process_char(ch)
50
+ if @cr_prev && (ch == "\n")
51
+ @cr_prev = false
52
+ return
53
+ end
54
+ @cr_prev = ch == "\r"
55
+ if @cr_prev || (ch == "\n")
56
+ line = @buffer.join
57
+ @buffer.clear
58
+ if line.empty?
59
+ dispatch_event
60
+ else
61
+ handle_line(line)
62
+ end
63
+ return
64
+ end
65
+ @buffer << ch
66
+ end
67
+
68
+ def handle_line(line)
69
+ field, value = parse_line(line)
70
+ return if field.nil?
71
+
72
+ case field
73
+ when 'event'
74
+ @event = value
75
+ when 'data'
76
+ @data_buffer << value
77
+ when 'id'
78
+ @id = value
79
+ when 'retry'
80
+ @reconnection_time = value.to_i # This does not affect anything
81
+ end
82
+ end
83
+
84
+ def parse_line(line)
85
+ colon_index = line.index(':')
86
+ return line, nil if colon_index.nil?
87
+ return nil, nil if colon_index.zero?
88
+
89
+ field = line[0, colon_index]
90
+ return field, '' if colon_index + 1 == line.length
91
+
92
+ value = if line[colon_index + 1] == ' '
93
+ line[colon_index + 2..]
94
+ else
95
+ line[colon_index + 1..]
96
+ end
97
+ [field, value]
98
+ end
99
+
100
+ def dispatch_event
101
+ return unless !@event.nil? || !@data_buffer.empty? # Ignoring empty events
102
+
103
+ data = @data_buffer.join("\n")
104
+ message = SseMessage.new(@event, @id, data)
105
+ @event = nil
106
+ @data_buffer.clear
107
+ @message_handler&.call(message)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kameleoon
4
+ module RealTime
5
+ ##
6
+ # SseMessage is a container for a received SSE message.
7
+ class SseMessage
8
+ attr_reader :event, :id, :data
9
+
10
+ ##
11
+ # Parametrized initializer.
12
+ #
13
+ # @param event [String]
14
+ # @param id [String]
15
+ # @param data [String]
16
+ def initialize(event, id, data)
17
+ @event = event
18
+ @id = id
19
+ @data = data
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'restclient'
4
+
5
+ module Kameleoon
6
+ module RealTime
7
+ ##
8
+ # SseRequest is used keep SSE connection and read from its stream.
9
+ class SseRequest
10
+ attr_writer :resp_char_handler
11
+
12
+ ##
13
+ # Parametrized initializer.
14
+ #
15
+ # @param url [String]
16
+ # @param headers [Hash]
17
+ # @param open_handler [Callable | NilClass] Handler which is synchronously called when SSE connection is open.
18
+ # @param close_handler [Callable | NilClass] Handler which is synchronously called when SSE connection is closed.
19
+ # @param unexpected_status_code_handler [Callable[Net::HTTPResponse] | NilClass] Handler whick
20
+ # is synchronously called for responses with not 200 status code.
21
+ def initialize(url, headers, open_handler, close_handler, unexpected_status_code_handler)
22
+ @url = url
23
+ @headers = headers
24
+ @open_handler = open_handler
25
+ @close_handler = close_handler
26
+ @unexpected_status_code_handler = unexpected_status_code_handler
27
+
28
+ @resp_char_handler = nil
29
+ end
30
+
31
+ ##
32
+ # Starts SSE connection and stay in the loop until close.
33
+ def start
34
+ RestClient::Request.execute(method: :get, url: @url, headers: @headers, read_timeout: nil,
35
+ block_response: method(:handle_resp))
36
+ end
37
+
38
+ ##
39
+ # Calls @close_handler if it is not nil.
40
+ def call_close_handler
41
+ @close_handler&.call
42
+ end
43
+
44
+ private
45
+
46
+ def handle_resp(resp)
47
+ @open_handler&.call
48
+ if resp.code == '200'
49
+ resp.read_body do |chunk|
50
+ chunk.each_char { |ch| @resp_char_handler&.call(ch) }
51
+ end
52
+ else
53
+ @unexpected_status_code_handler&.call(resp)
54
+ end
55
+ call_close_handler
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Kameleoon
6
+ module Storage
7
+ # Will be useful for Ruby 3.0
8
+ # Abstract Cache class (interface)
9
+ class Cache
10
+ def get(_key)
11
+ raise 'Abstract method `read` called'
12
+ end
13
+
14
+ def set(_key, _value)
15
+ raise 'Abstract method `write` called'
16
+ end
17
+
18
+ def active_items
19
+ raise 'Abstract method `active_items` called'
20
+ end
21
+ end
22
+
23
+ # Implementation of Cache with auto cleaning feature
24
+ class CacheImpl < Cache
25
+ def initialize(expiration_time, cleaning_interval)
26
+ super()
27
+ @mutex = Mutex.new
28
+ @expiration_time = expiration_time
29
+ @cleaning_interval = cleaning_interval
30
+ @cache = {}
31
+ end
32
+
33
+ def set(key, value)
34
+ @mutex.synchronize do
35
+ start_cleaner_timer if @cleaning_interval.positive? && @cleaner_timer.nil?
36
+ @cache[key] = { value: value, expired: Time.now + @expiration_time }
37
+ end
38
+ end
39
+
40
+ def get(key)
41
+ entry = @cache[key]
42
+ return entry[:value] unless entry.nil? || expired?(entry)
43
+
44
+ @mutex.synchronize { @cache.delete(key) }
45
+ nil
46
+ end
47
+
48
+ def active_items
49
+ active_items = {}
50
+ remove_expired_entries
51
+ @mutex.synchronize do
52
+ @cache.each_pair { |key, entry| active_items[key] = entry[:value] }
53
+ end
54
+ active_items
55
+ end
56
+
57
+ private
58
+
59
+ def start_cleaner_timer
60
+ @cleaner_timer = Concurrent::TimerTask.new(execution_interval: @cleaning_interval) do
61
+ remove_expired_entries
62
+ end
63
+ @cleaner_timer.execute
64
+ end
65
+
66
+ def stop_cleaner_timer
67
+ @cleaner_timer&.shutdown
68
+ @cleaner_timer = nil
69
+ end
70
+
71
+ def remove_expired_entries
72
+ time = Time.now
73
+ @mutex.synchronize do
74
+ @cache.delete_if { |_, entry| expired?(entry, time) }
75
+ stop_cleaner_timer if @cache.empty?
76
+ end
77
+ end
78
+
79
+ def expired?(entry, time = Time.now)
80
+ entry[:expired] <= time
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require_relative 'cache'
5
+
6
+ module Kameleoon
7
+ module Storage
8
+ # Will be useful for Ruby 3.0
9
+ # Abstract CacheFactory class (interface)
10
+ class CacheFactory
11
+ def create(_experiration_time, _cleaning_time)
12
+ raise 'Abstract method `create` called'
13
+ end
14
+ end
15
+
16
+ # Implementation of CacheFactory with auto cleaning feature
17
+ class CacheFactoryImpl < CacheFactory
18
+ def create(expiration_time, cleaning_interval)
19
+ CacheImpl.new(expiration_time, cleaning_interval)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -8,21 +8,21 @@ module Kameleoon
8
8
 
9
9
  def initialize(json_condition)
10
10
  if json_condition['targetingType'].nil?
11
- raise Exception::NotFoundError.new('targetingType'), 'targetingType missed'
11
+ raise Exception::NotFound.new('targetingType'), 'targetingType missed'
12
12
  end
13
13
 
14
14
  @type = json_condition['targetingType']
15
15
 
16
16
  if json_condition['include'].nil? && json_condition['isInclude'].nil?
17
- raise Exception::NotFoundError.new('include / isInclude missed'), 'include / isInclude missed'
17
+ raise Exception::NotFound.new('include / isInclude missed'), 'include / isInclude missed'
18
18
  end
19
19
 
20
20
  @include = json_condition['include'] || json_condition['isInclude']
21
21
  end
22
22
 
23
23
  def check(conditions)
24
- raise "Todo: Implement check method in condition"
24
+ raise 'Abstract method `check` call'
25
25
  end
26
26
  end
27
27
  end
28
- end
28
+ end