configcat 6.1.0 → 8.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -2,37 +2,51 @@ require 'configcat/utils'
2
2
 
3
3
  module ConfigCat
4
4
  class ConfigEntry
5
- CONFIG = 'config'
6
- ETAG = 'etag'
7
- FETCH_TIME = 'fetch_time'
5
+ attr_accessor :config, :etag, :config_json_string, :fetch_time
8
6
 
9
- attr_accessor :config, :etag, :fetch_time
10
-
11
- def initialize(config = {}, etag = '', fetch_time = Utils::DISTANT_PAST)
7
+ def initialize(config = {}, etag = '', config_json_string = '{}', fetch_time = Utils::DISTANT_PAST)
12
8
  @config = config
13
9
  @etag = etag
10
+ @config_json_string = config_json_string
14
11
  @fetch_time = fetch_time
15
12
  end
16
13
 
17
- def self.create_from_json(json)
18
- return ConfigEntry::EMPTY if json.nil?
19
- return ConfigEntry.new(
20
- config = json.fetch(CONFIG, {}),
21
- etag = json.fetch(ETAG, ''),
22
- fetch_time = json.fetch(FETCH_TIME, Utils::DISTANT_PAST)
23
- )
24
- end
25
-
26
14
  def empty?
27
15
  self == ConfigEntry::EMPTY
28
16
  end
29
17
 
30
- def to_json
31
- {
32
- CONFIG => config,
33
- ETAG => etag,
34
- FETCH_TIME => fetch_time
35
- }
18
+ def serialize
19
+ "#{(fetch_time * 1000).floor}\n#{etag}\n#{config_json_string}"
20
+ end
21
+
22
+ def self.create_from_string(string)
23
+ return ConfigEntry.empty if string.nil? || string.empty?
24
+
25
+ fetch_time_index = string.index("\n")
26
+ etag_index = string.index("\n", fetch_time_index + 1)
27
+ if fetch_time_index.nil? || etag_index.nil?
28
+ raise 'Number of values is fewer than expected.'
29
+ end
30
+
31
+ begin
32
+ fetch_time = Float(string[0...fetch_time_index])
33
+ rescue ArgumentError
34
+ raise "Invalid fetch time: #{string[0...fetch_time_index]}"
35
+ end
36
+
37
+ etag = string[fetch_time_index + 1...etag_index]
38
+ if etag.nil? || etag.empty?
39
+ raise 'Empty eTag value'
40
+ end
41
+ begin
42
+ config_json = string[etag_index + 1..-1]
43
+ config = JSON.parse(config_json)
44
+ Config.fixup_config_salt_and_segments(config)
45
+ rescue => e
46
+ raise "Invalid config JSON: #{config_json}. #{e.message}"
47
+ end
48
+
49
+ ConfigEntry.new(config, etag, config_json, fetch_time / 1000.0)
36
50
  end
37
51
 
38
52
  EMPTY = ConfigEntry.new(etag: 'empty')
@@ -1,7 +1,7 @@
1
1
  require 'configcat/interfaces'
2
2
  require 'configcat/version'
3
3
  require 'configcat/datagovernance'
4
- require 'configcat/constants'
4
+ require 'configcat/config'
5
5
  require 'configcat/configentry'
6
6
  require 'net/http'
7
7
  require 'uri'
@@ -20,8 +20,8 @@ module ConfigCat
20
20
  end
21
21
 
22
22
  class Status
23
- FETCHED = 0,
24
- NOT_MODIFIED = 1,
23
+ FETCHED = 0
24
+ NOT_MODIFIED = 1
25
25
  FAILURE = 2
26
26
  end
27
27
 
@@ -179,7 +179,8 @@ module ConfigCat
179
179
  response_etag = ""
180
180
  end
181
181
  config = JSON.parse(response.body)
182
- return FetchResponse.success(ConfigEntry.new(config, response_etag, Utils.get_utc_now_seconds_since_epoch))
182
+ Config.fixup_config_salt_and_segments(config)
183
+ return FetchResponse.success(ConfigEntry.new(config, response_etag, response.body, Utils.get_utc_now_seconds_since_epoch))
183
184
  when Net::HTTPNotModified
184
185
  return FetchResponse.not_modified
185
186
  when Net::HTTPNotFound, Net::HTTPForbidden
@@ -198,7 +199,9 @@ module ConfigCat
198
199
  @log.error(1102, error)
199
200
  return FetchResponse.failure(error, true)
200
201
  rescue Exception => e
201
- error = "Unexpected error occurred while trying to fetch config JSON: #{e}"
202
+ error = "Unexpected error occurred while trying to fetch config JSON. It is most likely due to a local network " \
203
+ "issue. Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) " \
204
+ "over HTTP. #{e}"
202
205
  @log.error(1103, error)
203
206
  return FetchResponse.failure(error, true)
204
207
  end
@@ -7,14 +7,13 @@ require 'configcat/refreshresult'
7
7
  module ConfigCat
8
8
  class ConfigService
9
9
  def initialize(sdk_key, polling_mode, hooks, config_fetcher, log, config_cache, is_offline)
10
- @sdk_key = sdk_key
11
10
  @cached_entry = ConfigEntry::EMPTY
12
11
  @cached_entry_string = ''
13
12
  @polling_mode = polling_mode
14
13
  @log = log
15
14
  @config_cache = config_cache
16
15
  @hooks = hooks
17
- @cache_key = Digest::SHA1.hexdigest("ruby_#{CONFIG_FILE_NAME}_#{@sdk_key}")
16
+ @cache_key = ConfigService.get_cache_key(sdk_key)
18
17
  @config_fetcher = config_fetcher
19
18
  @is_offline = is_offline
20
19
  @response_future = nil
@@ -31,13 +30,12 @@ module ConfigCat
31
30
  end
32
31
  end
33
32
 
34
- def get_settings
33
+ def get_config
35
34
  if @polling_mode.is_a?(LazyLoadingMode)
36
35
  entry, _ = fetch_if_older(Utils.get_utc_now_seconds_since_epoch - @polling_mode.cache_refresh_interval_seconds)
37
36
  return !entry.empty? ?
38
- [entry.config.fetch(FEATURE_FLAGS, {}), entry.fetch_time] :
37
+ [entry.config, entry.fetch_time] :
39
38
  [nil, Utils::DISTANT_PAST]
40
-
41
39
  elsif @polling_mode.is_a?(AutoPollingMode) && !@initialized.set?
42
40
  elapsed_time = Utils.get_utc_now_seconds_since_epoch - @start_time # Elapsed time in seconds
43
41
  if elapsed_time < @polling_mode.max_init_wait_time_seconds
@@ -47,20 +45,27 @@ module ConfigCat
47
45
  if !@initialized.set?
48
46
  set_initialized
49
47
  return !@cached_entry.empty? ?
50
- [@cached_entry.config.fetch(FEATURE_FLAGS, {}), @cached_entry.fetch_time] :
48
+ [@cached_entry.config, @cached_entry.fetch_time] :
51
49
  [nil, Utils::DISTANT_PAST]
52
50
  end
53
51
  end
54
52
  end
55
53
 
56
- entry, _ = fetch_if_older(Utils::DISTANT_PAST, prefer_cache: true)
54
+ # If we are initialized, we prefer the cached results
55
+ entry, _ = fetch_if_older(Utils::DISTANT_PAST, prefer_cache: @initialized.set?)
57
56
  return !entry.empty? ?
58
- [entry.config.fetch(FEATURE_FLAGS, {}), entry.fetch_time] :
57
+ [entry.config, entry.fetch_time] :
59
58
  [nil, Utils::DISTANT_PAST]
60
59
  end
61
60
 
62
61
  # :return [RefreshResult]
63
62
  def refresh
63
+ if offline?
64
+ offline_warning = "Client is in offline mode, it cannot initiate HTTP calls."
65
+ @log.warn(3200, offline_warning)
66
+ return RefreshResult.new(success = false, error = offline_warning)
67
+ end
68
+
64
69
  _, error = fetch_if_older(Utils::DISTANT_FUTURE)
65
70
  return RefreshResult.new(success = error.nil?, error = error)
66
71
  end
@@ -75,7 +80,7 @@ module ConfigCat
75
80
  if @polling_mode.is_a?(AutoPollingMode)
76
81
  start_poll
77
82
  end
78
- @log.info(5200, 'Switched to ONLINE mode.')
83
+ @log.info(5200, "Switched to ONLINE mode.")
79
84
  end
80
85
  end
81
86
 
@@ -91,7 +96,7 @@ module ConfigCat
91
96
  @thread.join
92
97
  end
93
98
 
94
- @log.info(5200, 'Switched to OFFLINE mode.')
99
+ @log.info(5200, "Switched to OFFLINE mode.")
95
100
  end
96
101
  end
97
102
 
@@ -107,36 +112,30 @@ module ConfigCat
107
112
 
108
113
  private
109
114
 
115
+ def self.get_cache_key(sdk_key)
116
+ Digest::SHA1.hexdigest("#{sdk_key}_#{CONFIG_FILE_NAME}.json_#{SERIALIZATION_FORMAT_VERSION}")
117
+ end
118
+
110
119
  # :return [ConfigEntry, String] Returns the ConfigEntry object and error message in case of any error.
111
- def fetch_if_older(time, prefer_cache: false)
120
+ def fetch_if_older(threshold, prefer_cache: false)
112
121
  # Sync up with the cache and use it when it's not expired.
113
122
  @lock.synchronize do
114
- if @cached_entry.empty? || @cached_entry.fetch_time > time
115
- entry = read_cache
116
- if !entry.empty? && entry.etag != @cached_entry.etag
117
- @cached_entry = entry
118
- @hooks.invoke_on_config_changed(entry.config[FEATURE_FLAGS])
119
- end
120
-
121
- # Cache isn't expired
122
- if @cached_entry.fetch_time > time
123
- set_initialized
124
- return @cached_entry, nil
125
- end
123
+ # Sync up with the cache and use it when it's not expired.
124
+ from_cache = read_cache
125
+ if !from_cache.empty? && from_cache.etag != @cached_entry.etag
126
+ @cached_entry = from_cache
127
+ @hooks.invoke_on_config_changed(from_cache.config[FEATURE_FLAGS])
126
128
  end
127
129
 
128
- # Use cache anyway (get calls on auto & manual poll must not initiate fetch).
129
- # The initialized check ensures that we subscribe for the ongoing fetch during the
130
- # max init wait time window in case of auto poll.
131
- if prefer_cache && @initialized.set?
130
+ # Cache isn't expired
131
+ if @cached_entry.fetch_time > threshold
132
+ set_initialized
132
133
  return @cached_entry, nil
133
134
  end
134
135
 
135
- # If we are in offline mode we are not allowed to initiate fetch.
136
- if @is_offline
137
- offline_warning = "Client is in offline mode, it cannot initiate HTTP calls."
138
- @log.warn(3200, offline_warning)
139
- return @cached_entry, offline_warning
136
+ # If we are in offline mode or the caller prefers cached values, do not initiate fetch.
137
+ if @is_offline || prefer_cache
138
+ return @cached_entry, nil
140
139
  end
141
140
  end
142
141
 
@@ -201,7 +200,7 @@ module ConfigCat
201
200
  end
202
201
 
203
202
  @cached_entry_string = json_string
204
- return ConfigEntry.create_from_json(JSON.parse(json_string))
203
+ return ConfigEntry.create_from_string(json_string)
205
204
  rescue Exception => e
206
205
  @log.error(2200, "Error occurred while reading the cache. #{e}")
207
206
  return ConfigEntry::EMPTY
@@ -210,7 +209,7 @@ module ConfigCat
210
209
 
211
210
  def write_cache(config_entry)
212
211
  begin
213
- @config_cache.set(@cache_key, config_entry.to_json.to_json)
212
+ @config_cache.set(@cache_key, config_entry.serialize)
214
213
  rescue Exception => e
215
214
  @log.error(2201, "Error occurred while writing the cache. #{e}")
216
215
  end
@@ -0,0 +1,14 @@
1
+ module ConfigCat
2
+ class EvaluationContext
3
+ attr_accessor :key, :setting_type, :user, :visited_keys, :is_missing_user_object_logged, :is_missing_user_object_attribute_logged
4
+
5
+ def initialize(key, setting_type, user, visited_keys = nil, is_missing_user_object_logged = false, is_missing_user_object_attribute_logged = false)
6
+ @key = key
7
+ @setting_type = setting_type
8
+ @user = user
9
+ @visited_keys = visited_keys || []
10
+ @is_missing_user_object_logged = is_missing_user_object_logged
11
+ @is_missing_user_object_attribute_logged = is_missing_user_object_attribute_logged
12
+ end
13
+ end
14
+ end
@@ -1,19 +1,37 @@
1
1
  module ConfigCat
2
2
  class EvaluationDetails
3
3
  attr_reader :key, :value, :variation_id, :fetch_time, :user, :is_default_value, :error,
4
- :matched_evaluation_rule, :matched_evaluation_percentage_rule
4
+ :matched_targeting_rule, :matched_percentage_option
5
5
 
6
6
  def initialize(key:, value:, variation_id: nil, fetch_time: nil, user: nil, is_default_value: false, error: nil,
7
- matched_evaluation_rule: nil, matched_evaluation_percentage_rule: nil)
7
+ matched_targeting_rule: nil, matched_percentage_option: nil)
8
+ # Key of the feature flag or setting.
8
9
  @key = key
10
+
11
+ # Evaluated value of the feature flag or setting.
9
12
  @value = value
13
+
14
+ # Variation ID of the feature flag or setting (if available).
10
15
  @variation_id = variation_id
16
+
17
+ # Time of last successful config download.
11
18
  @fetch_time = fetch_time
19
+
20
+ # The User Object used for the evaluation (if available).
12
21
  @user = user
22
+
23
+ # Indicates whether the default value passed to the setting evaluation methods like ConfigCatClient.get_value,
24
+ # ConfigCatClient.get_value_details, etc. is used as the result of the evaluation.
13
25
  @is_default_value = is_default_value
26
+
27
+ # Error message in case evaluation failed.
14
28
  @error = error
15
- @matched_evaluation_rule = matched_evaluation_rule
16
- @matched_evaluation_percentage_rule = matched_evaluation_percentage_rule
29
+
30
+ # The targeting rule (if any) that matched during the evaluation and was used to return the evaluated value.
31
+ @matched_targeting_rule = matched_targeting_rule
32
+
33
+ # The percentage option (if any) that was used to select the evaluated value.
34
+ @matched_percentage_option = matched_percentage_option
17
35
  end
18
36
 
19
37
  def self.from_error(key, value, error:, variation_id: nil)
@@ -0,0 +1,81 @@
1
+ module ConfigCat
2
+ class EvaluationLogBuilder
3
+ def initialize
4
+ @indent_level = 0
5
+ @text = ''
6
+ end
7
+
8
+ def self.trunc_comparison_value_if_needed(comparator, comparison_value)
9
+ if [
10
+ Comparator::IS_ONE_OF_HASHED,
11
+ Comparator::IS_NOT_ONE_OF_HASHED,
12
+ Comparator::EQUALS_HASHED,
13
+ Comparator::NOT_EQUALS_HASHED,
14
+ Comparator::STARTS_WITH_ANY_OF_HASHED,
15
+ Comparator::NOT_STARTS_WITH_ANY_OF_HASHED,
16
+ Comparator::ENDS_WITH_ANY_OF_HASHED,
17
+ Comparator::NOT_ENDS_WITH_ANY_OF_HASHED,
18
+ Comparator::ARRAY_CONTAINS_ANY_OF_HASHED,
19
+ Comparator::ARRAY_NOT_CONTAINS_ANY_OF_HASHED
20
+ ].include?(comparator)
21
+ if comparison_value.is_a?(Array)
22
+ length = comparison_value.length
23
+ if length > 1
24
+ return "[<#{length} hashed values>]"
25
+ end
26
+ return "[<#{length} hashed value>]"
27
+ end
28
+
29
+ return "'<hashed value>'"
30
+ end
31
+
32
+ if comparison_value.is_a?(Array)
33
+ length_limit = 10
34
+ length = comparison_value.length
35
+ if length > length_limit
36
+ remaining = length - length_limit
37
+ more_text = remaining == 1 ? "<1 more value>" : "<#{remaining} more values>"
38
+
39
+ formatted_strings = comparison_value.first(length_limit).map { |str| "'#{str}'" }.join(", ")
40
+ return "[#{formatted_strings}, ... #{more_text}]"
41
+ end
42
+
43
+ # replace '"' with "'" in the string representation of the array
44
+ formatted_strings = comparison_value.map { |str| "'#{str}'" }.join(", ")
45
+ return "[#{formatted_strings}]"
46
+ end
47
+
48
+ if [Comparator::BEFORE_DATETIME, Comparator::AFTER_DATETIME].include?(comparator)
49
+ time = Utils.get_date_time(comparison_value)
50
+ return "'#{comparison_value}' (#{time.strftime('%Y-%m-%dT%H:%M:%S.%L')}Z UTC)"
51
+ end
52
+
53
+ "'#{comparison_value.to_s}'"
54
+ end
55
+
56
+ def increase_indent
57
+ @indent_level += 1
58
+ self
59
+ end
60
+
61
+ def decrease_indent
62
+ @indent_level = [@indent_level - 1, 0].max
63
+ self
64
+ end
65
+
66
+ def append(text)
67
+ @text += text
68
+ self
69
+ end
70
+
71
+ def new_line(text = nil)
72
+ @text += "\n" + ' ' * @indent_level
73
+ @text += text if text
74
+ self
75
+ end
76
+
77
+ def to_s
78
+ @text
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,5 @@
1
1
  require 'configcat/overridedatasource'
2
- require 'configcat/constants'
2
+ require 'configcat/config'
3
3
 
4
4
 
5
5
  module ConfigCat
@@ -17,14 +17,30 @@ module ConfigCat
17
17
  class LocalDictionaryDataSource < OverrideDataSource
18
18
  def initialize(source, override_behaviour)
19
19
  super(override_behaviour)
20
- @_settings = {}
20
+ @_config = {}
21
21
  source.each do |key, value|
22
- @_settings[key] = { VALUE => value }
22
+ value_type = case value
23
+ when TrueClass, FalseClass
24
+ BOOL_VALUE
25
+ when String
26
+ STRING_VALUE
27
+ when Integer
28
+ INT_VALUE
29
+ when Float
30
+ DOUBLE_VALUE
31
+ else
32
+ UNSUPPORTED_VALUE
33
+ end
34
+
35
+ @_config[FEATURE_FLAGS] ||= {}
36
+ @_config[FEATURE_FLAGS][key] = { VALUE => { value_type => value } }
37
+ setting_type = SettingType.from_type(value.class)
38
+ @_config[FEATURE_FLAGS][key][SETTING_TYPE] = setting_type.to_i unless setting_type.nil?
23
39
  end
24
40
  end
25
41
 
26
42
  def get_overrides
27
- return @_settings
43
+ return @_config
28
44
  end
29
45
  end
30
46
  end
@@ -1,5 +1,5 @@
1
1
  require 'configcat/overridedatasource'
2
- require 'configcat/constants'
2
+ require 'configcat/config'
3
3
 
4
4
 
5
5
  module ConfigCat
@@ -18,17 +18,17 @@ module ConfigCat
18
18
  def initialize(file_path, override_behaviour, log)
19
19
  super(override_behaviour)
20
20
  @log = log
21
- if !File.exists?(file_path)
21
+ if !File.exist?(file_path)
22
22
  @log.error(1300, "Cannot find the local config file '#{file_path}'. This is a path that your application provided to the ConfigCat SDK by passing it to the `LocalFileFlagOverrides.new()` method. Read more: https://configcat.com/docs/sdk-reference/ruby/#json-file")
23
23
  end
24
24
  @_file_path = file_path
25
- @_settings = nil
25
+ @_config = nil
26
26
  @_cached_file_stamp = 0
27
27
  end
28
28
 
29
29
  def get_overrides
30
30
  reload_file_content()
31
- return @_settings
31
+ return @_config
32
32
  end
33
33
 
34
34
  private
@@ -41,13 +41,29 @@ module ConfigCat
41
41
  file = File.read(@_file_path)
42
42
  data = JSON.parse(file)
43
43
  if data.key?("flags")
44
- @_settings = {}
44
+ @_config = { FEATURE_FLAGS => {} }
45
45
  source = data["flags"]
46
46
  source.each do |key, value|
47
- @_settings[key] = { VALUE => value }
47
+ value_type = case value
48
+ when true, false
49
+ BOOL_VALUE
50
+ when String
51
+ STRING_VALUE
52
+ when Integer
53
+ INT_VALUE
54
+ when Float
55
+ DOUBLE_VALUE
56
+ else
57
+ UNSUPPORTED_VALUE
58
+ end
59
+
60
+ @_config[FEATURE_FLAGS][key] = { VALUE => { value_type => value } }
61
+ setting_type = SettingType.from_type(value.class)
62
+ @_config[FEATURE_FLAGS][key][SETTING_TYPE] = setting_type.to_i unless setting_type.nil?
48
63
  end
49
64
  else
50
- @_settings = data[FEATURE_FLAGS]
65
+ Config.fixup_config_salt_and_segments(data)
66
+ @_config = data
51
67
  end
52
68
  end
53
69
  rescue JSON::ParserError => e