configcat 6.1.0 → 8.0.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.
@@ -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