kameleoon-client-ruby 1.1.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kameleoon/client.rb +541 -404
  3. data/lib/kameleoon/configuration/experiment.rb +42 -0
  4. data/lib/kameleoon/configuration/feature_flag.rb +30 -0
  5. data/lib/kameleoon/configuration/rule.rb +57 -0
  6. data/lib/kameleoon/configuration/settings.rb +20 -0
  7. data/lib/kameleoon/configuration/variable.rb +23 -0
  8. data/lib/kameleoon/configuration/variation.rb +31 -0
  9. data/lib/kameleoon/configuration/variation_exposition.rb +23 -0
  10. data/lib/kameleoon/cookie.rb +13 -6
  11. data/lib/kameleoon/data.rb +60 -40
  12. data/lib/kameleoon/exceptions.rb +46 -23
  13. data/lib/kameleoon/factory.rb +21 -18
  14. data/lib/kameleoon/hybrid/manager.rb +60 -0
  15. data/lib/kameleoon/real_time/real_time_configuration_service.rb +98 -0
  16. data/lib/kameleoon/real_time/real_time_event.rb +22 -0
  17. data/lib/kameleoon/real_time/sse_client.rb +111 -0
  18. data/lib/kameleoon/real_time/sse_message.rb +23 -0
  19. data/lib/kameleoon/real_time/sse_request.rb +59 -0
  20. data/lib/kameleoon/request.rb +14 -13
  21. data/lib/kameleoon/storage/cache.rb +84 -0
  22. data/lib/kameleoon/storage/cache_factory.rb +23 -0
  23. data/lib/kameleoon/storage/variation_storage.rb +42 -0
  24. data/lib/kameleoon/storage/visitor_variation.rb +20 -0
  25. data/lib/kameleoon/targeting/condition.rb +17 -5
  26. data/lib/kameleoon/targeting/condition_factory.rb +9 -2
  27. data/lib/kameleoon/targeting/conditions/custom_datum.rb +67 -48
  28. data/lib/kameleoon/targeting/conditions/exclusive_experiment.rb +29 -0
  29. data/lib/kameleoon/targeting/conditions/target_experiment.rb +44 -0
  30. data/lib/kameleoon/targeting/models.rb +36 -36
  31. data/lib/kameleoon/utils.rb +4 -1
  32. data/lib/kameleoon/version.rb +4 -2
  33. metadata +35 -3
  34. data/lib/kameleoon/query_graphql.rb +0 -76
@@ -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
@@ -1,16 +1,18 @@
1
- require "em-synchrony/em-http"
2
- require "kameleoon/version"
1
+ require 'em-synchrony/em-http'
2
+ require 'kameleoon/version'
3
3
  require 'net/http'
4
4
 
5
5
  module Kameleoon
6
6
  # @api private
7
7
  module Request
8
8
  protected
9
- API_URL = "https://api.kameleoon.com"
9
+
10
+ API_URL = 'https://api.kameleoon.com'.freeze
11
+ CLIENT_CONFIG_URL = 'https://client-config.kameleoon.com'.freeze
10
12
 
11
13
  module Method
12
- GET = "get"
13
- POST = "post"
14
+ GET = 'get'.freeze
15
+ POST = 'post'.freeze
14
16
  end
15
17
 
16
18
  def get(request_options, url = API_URL, connexion_options = {})
@@ -32,7 +34,7 @@ module Kameleoon
32
34
  private
33
35
 
34
36
  def request(method, request_options, url, connexion_options)
35
- connexion_options[:tls] = {verify_peer: false}
37
+ connexion_options[:tls] = { verify_peer: false }
36
38
  add_user_agent(request_options)
37
39
  case method
38
40
  when Method::POST then
@@ -40,7 +42,7 @@ module Kameleoon
40
42
  when Method::GET then
41
43
  return EventMachine::HttpRequest.new(url, connexion_options).get request_options
42
44
  else
43
- print "Unknown request type"
45
+ print 'Unknown request type'
44
46
  return false
45
47
  end
46
48
  end
@@ -68,22 +70,21 @@ module Kameleoon
68
70
  end
69
71
  end
70
72
 
71
- def is_successful(request)
73
+ def successful?(request)
72
74
  !request.nil? && request != false && /20\d/.match(request.response_header.status.to_s)
73
75
  end
74
76
 
75
- def is_successful_sync(response)
77
+ def successful_sync?(response)
76
78
  !response.nil? && response != false && response.is_a?(Net::HTTPSuccess)
77
79
  end
78
80
 
79
81
  def add_user_agent(request_options)
82
+ sdk_version = "sdk/ruby/#{Kameleoon::VERSION}"
80
83
  if request_options[:head].nil?
81
- request_options[:head] = {'Kameleoon-Client' => 'sdk/ruby/' + Kameleoon::VERSION}
84
+ request_options[:head] = { 'Kameleoon-Client' => sdk_version }
82
85
  else
83
- request_options[:head].store('Kameleoon-Client', 'sdk/ruby/' + Kameleoon::VERSION)
86
+ request_options[:head].store('Kameleoon-Client', sdk_version)
84
87
  end
85
88
  end
86
89
  end
87
90
  end
88
-
89
-
@@ -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
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kameleoon/storage/visitor_variation'
4
+
5
+ module Kameleoon
6
+ module Storage
7
+ # VariationStorage is a container for saved variations associated with a visitor
8
+ class VariationStorage
9
+ def initialize
10
+ @storage = {}
11
+ end
12
+
13
+ def get_variation_id(visitor_code, experiment_id)
14
+ variation_valid?(visitor_code, experiment_id, nil)
15
+ end
16
+
17
+ def variation_valid?(visitor_code, experiment_id, respool_time)
18
+ return nil if @storage[visitor_code].nil? || @storage[visitor_code][experiment_id].nil?
19
+
20
+ variation = @storage[visitor_code][experiment_id]
21
+ return nil unless variation.valid?(respool_time)
22
+
23
+ variation.variation_id
24
+ end
25
+
26
+ def update_variation(visitor_code, experiment_id, variation_id)
27
+ @storage[visitor_code] = {} if @storage[visitor_code].nil?
28
+ @storage[visitor_code][experiment_id] = Kameleoon::Storage::VisitorVariation.new(variation_id)
29
+ end
30
+
31
+ def get_hash_saved_variation_id(visitor_code)
32
+ return nil if @storage[visitor_code].nil?
33
+
34
+ map_variations = {}
35
+ @storage[visitor_code].each do |key, value|
36
+ map_variations[key] = value.variation_id
37
+ end
38
+ map_variations
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kameleoon
4
+ module Storage
5
+ # VisitorVariation contains a saved variation id associated with a visitor
6
+ # and time when it was associated.
7
+ class VisitorVariation
8
+ attr_accessor :variation_id
9
+
10
+ def initialize(variation_id)
11
+ @variation_id = variation_id
12
+ @assignment_date = Time.now.to_i
13
+ end
14
+
15
+ def valid?(respool_time)
16
+ respool_time.nil? || @assignment_date > respool_time
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,16 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kameleoon
2
- #@api private
4
+ # @api private
3
5
  module Targeting
4
6
  class Condition
5
7
  attr_accessor :type, :include
6
8
 
7
- def initialize(json_conditions)
8
- raise "Abstract class cannot be instantiate"
9
+ def initialize(json_condition)
10
+ if json_condition['targetingType'].nil?
11
+ raise Exception::NotFound.new('targetingType'), 'targetingType missed'
12
+ end
13
+
14
+ @type = json_condition['targetingType']
15
+
16
+ if json_condition['include'].nil? && json_condition['isInclude'].nil?
17
+ raise Exception::NotFound.new('include / isInclude missed'), 'include / isInclude missed'
18
+ end
19
+
20
+ @include = json_condition['include'] || json_condition['isInclude']
9
21
  end
10
22
 
11
23
  def check(conditions)
12
- raise "Todo: Implement check method in condition"
24
+ raise 'Abstract method `check` call'
13
25
  end
14
26
  end
15
27
  end
16
- end
28
+ end
@@ -1,4 +1,6 @@
1
1
  require 'kameleoon/targeting/conditions/custom_datum'
2
+ require 'kameleoon/targeting/conditions/target_experiment'
3
+ require 'kameleoon/targeting/conditions/exclusive_experiment'
2
4
 
3
5
  module Kameleoon
4
6
  #@api private
@@ -6,11 +8,16 @@ module Kameleoon
6
8
  module ConditionFactory
7
9
  def get_condition(condition_json)
8
10
  condition = nil
9
- if condition_json['targetingType'] == ConditionType::CUSTOM_DATUM.to_s
11
+ case condition_json['targetingType']
12
+ when ConditionType::CUSTOM_DATUM.to_s
10
13
  condition = CustomDatum.new(condition_json)
14
+ when ConditionType::TARGET_EXPERIMENT.to_s
15
+ condition = TargetExperiment.new(condition_json)
16
+ when ConditionType::EXCLUSIVE_EXPERIMENT.to_s
17
+ condition = ExclusiveExperiment.new(condition_json)
11
18
  end
12
19
  condition
13
20
  end
14
21
  end
15
22
  end
16
- end
23
+ end