configcat 7.0.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.
@@ -1,20 +1,26 @@
1
1
  require 'configcat/user'
2
- require 'configcat/constants'
2
+ require 'configcat/config'
3
+ require 'configcat/evaluationcontext'
4
+ require 'configcat/evaluationlogbuilder'
3
5
  require 'digest'
4
6
  require 'semantic'
5
7
 
8
+
6
9
  module ConfigCat
7
10
  class RolloutEvaluator
8
- COMPARATOR_TEXTS = ["IS ONE OF", "IS NOT ONE OF", "CONTAINS", "DOES NOT CONTAIN", "IS ONE OF (SemVer)", "IS NOT ONE OF (SemVer)", "< (SemVer)", "<= (SemVer)", "> (SemVer)", ">= (SemVer)", "= (Number)", "<> (Number)", "< (Number)", "<= (Number)", "> (Number)", ">= (Number)"]
9
-
10
11
  def initialize(log)
11
12
  @log = log
12
13
  end
13
14
 
14
- # :returns value, variation_id. matched_evaluation_rule, matched_evaluation_percentage_rule, error
15
- def evaluate(key:, user:, default_value:, default_variation_id:, settings:)
15
+ # :returns value, variation_id. matched_targeting_rule, matched_percentage_option, error
16
+ def evaluate(key:, user:, default_value:, default_variation_id:, config:, log_builder:, visited_keys: nil)
17
+ visited_keys ||= []
18
+ is_root_flag_evaluation = visited_keys.empty?
19
+
20
+ settings = config[FEATURE_FLAGS] || {}
16
21
  setting_descriptor = settings[key]
17
- if setting_descriptor === nil
22
+
23
+ if setting_descriptor.nil?
18
24
  error = "Failed to evaluate setting '#{key}' (the key was not found in config JSON). " \
19
25
  "Returning the `default_value` parameter that you specified in your application: '#{default_value}'. " \
20
26
  "Available keys: [#{settings.keys.map { |s| "'#{s}'" }.join(", ")}]."
@@ -22,176 +28,733 @@ module ConfigCat
22
28
  return default_value, default_variation_id, nil, nil, error
23
29
  end
24
30
 
25
- rollout_rules = setting_descriptor.fetch(ROLLOUT_RULES, [])
26
- rollout_percentage_items = setting_descriptor.fetch(ROLLOUT_PERCENTAGE_ITEMS, [])
31
+ setting_type = setting_descriptor[SETTING_TYPE]
32
+ salt = setting_descriptor[INLINE_SALT] || ''
33
+ targeting_rules = setting_descriptor[TARGETING_RULES] || []
34
+ percentage_rule_attribute = setting_descriptor[PERCENTAGE_RULE_ATTRIBUTE]
27
35
 
28
- user_has_invalid_type = !user.equal?(nil) && !user.class.equal?(User)
36
+ context = EvaluationContext.new(key, setting_type, user, visited_keys)
37
+ user_has_invalid_type = context.user && !context.user.is_a?(User)
29
38
  if user_has_invalid_type
30
- @log.warn(4001, "Cannot evaluate targeting rules and % options for setting '#{key}' (User Object is not an instance of User type).")
31
- user = nil
39
+ @log.warn(4001, "Cannot evaluate targeting rules and % options for setting '#{key}' " \
40
+ "(User Object is not an instance of User type). " \
41
+ "You should pass a User Object to the evaluation methods like `get_value()` " \
42
+ "in order to make targeting work properly. " \
43
+ "Read more: https://configcat.com/docs/advanced/user-object/")
44
+ # We set the user to nil and won't log further missing user object warnings
45
+ context.user = nil
46
+ context.is_missing_user_object_logged = true
32
47
  end
33
- if user === nil
34
- if !user_has_invalid_type && (rollout_rules.size > 0 || rollout_percentage_items.size > 0)
35
- @log.warn(3001, "Cannot evaluate targeting rules and % options for setting '#{key}' (User Object is missing). " \
36
- "You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. " \
37
- "Read more: https://configcat.com/docs/advanced/user-object/")
38
- end
39
- return_value = setting_descriptor.fetch(VALUE, default_value)
40
- return_variation_id = setting_descriptor.fetch(VARIATION_ID, default_variation_id)
41
- @log.info(5000, "Returning [#{return_value}]")
42
- return return_value, return_variation_id, nil, nil, nil
43
- end
44
-
45
- log_entries = ["Evaluating get_value('%s')." % key, "User object:\n%s" % user.to_s]
46
48
 
47
49
  begin
48
- # Evaluate targeting rules
49
- for rollout_rule in rollout_rules
50
- comparison_attribute = rollout_rule.fetch(COMPARISON_ATTRIBUTE)
51
- comparison_value = rollout_rule.fetch(COMPARISON_VALUE, nil)
52
- comparator = rollout_rule.fetch(COMPARATOR, nil)
53
-
54
- user_value = user.get_attribute(comparison_attribute)
55
- if user_value === nil || !user_value
56
- log_entries.push(format_no_match_rule(comparison_attribute, user_value, comparator, comparison_value))
57
- next
58
- end
50
+ if log_builder && is_root_flag_evaluation
51
+ log_builder.append("Evaluating '#{key}'")
52
+ log_builder.append(" for User '#{context.user}'") if context.user
53
+ log_builder.increase_indent
54
+ end
55
+
56
+ # Evaluate targeting rules (logically connected by OR)
57
+ if log_builder && targeting_rules.any?
58
+ log_builder.new_line("Evaluating targeting rules and applying the first match if any:")
59
+ end
60
+ targeting_rules.each do |targeting_rule|
61
+ conditions = targeting_rule[CONDITIONS] || []
59
62
 
60
- value = rollout_rule.fetch(VALUE, nil)
61
- variation_id = rollout_rule.fetch(VARIATION_ID, default_variation_id)
63
+ if conditions.any?
64
+ served_value = targeting_rule[SERVED_VALUE]
65
+ value = Config.get_value(served_value, setting_type) if served_value
62
66
 
63
- # IS ONE OF
64
- if comparator == 0
65
- if comparison_value.to_s.split(",").map { |x| x.strip() }.include?(user_value.to_s)
66
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
67
- return value, variation_id, rollout_rule, nil, nil
68
- end
69
- # IS NOT ONE OF
70
- elsif comparator == 1
71
- if !comparison_value.to_s.split(",").map { |x| x.strip() }.include?(user_value.to_s)
72
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
73
- return value, variation_id, rollout_rule, nil, nil
74
- end
75
- # CONTAINS
76
- elsif comparator == 2
77
- if user_value.to_s.include?(comparison_value.to_s)
78
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
79
- return value, variation_id, rollout_rule, nil, nil
80
- end
81
- # DOES NOT CONTAIN
82
- elsif comparator == 3
83
- if !user_value.to_s.include?(comparison_value.to_s)
84
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
85
- return value, variation_id, rollout_rule, nil, nil
86
- end
87
- # IS ONE OF, IS NOT ONE OF (Semantic version)
88
- elsif (4 <= comparator) && (comparator <= 5)
89
- begin
90
- match = false
91
- user_value_version = Semantic::Version.new(user_value.to_s.strip())
92
- ((comparison_value.to_s.split(",").map { |x| x.strip() }).reject { |c| c.empty? }).each { |x|
93
- version = Semantic::Version.new(x)
94
- match = (user_value_version == version) || match
95
- }
96
- if match && comparator == 4 || !match && comparator == 5
97
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
98
- return value, variation_id, rollout_rule, nil, nil
67
+ # Evaluate targeting rule conditions (logically connected by AND)
68
+ if evaluate_conditions(conditions, context, salt, config, log_builder, value)
69
+ if served_value
70
+ variation_id = served_value[VARIATION_ID] || default_variation_id
71
+ log_builder.new_line("Returning '#{value}'.") if log_builder && is_root_flag_evaluation
72
+ return [value, variation_id, targeting_rule, nil, nil]
99
73
  end
100
- rescue ArgumentError => e
101
- message = format_validation_error_rule(comparison_attribute, user_value, comparator, comparison_value, e.to_s)
102
- @log.warn(0, message)
103
- log_entries.push(message)
74
+ else
104
75
  next
105
76
  end
106
- # LESS THAN, LESS THAN OR EQUALS TO, GREATER THAN, GREATER THAN OR EQUALS TO (Semantic version)
107
- elsif (6 <= comparator) && (comparator <= 9)
108
- begin
109
- user_value_version = Semantic::Version.new(user_value.to_s.strip())
110
- comparison_value_version = Semantic::Version.new(comparison_value.to_s.strip())
111
- if (comparator == 6 && user_value_version < comparison_value_version) ||
112
- (comparator == 7 && user_value_version <= comparison_value_version) ||
113
- (comparator == 8 && user_value_version > comparison_value_version) ||
114
- (comparator == 9 && user_value_version >= comparison_value_version)
115
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
116
- return value, variation_id, rollout_rule, nil, nil
117
- end
118
- rescue ArgumentError => e
119
- message = format_validation_error_rule(comparison_attribute, user_value, comparator, comparison_value, e.to_s)
120
- @log.warn(0, message)
121
- log_entries.push(message)
122
- next
123
- end
124
- elsif (10 <= comparator) && (comparator <= 15)
125
- begin
126
- user_value_float = Float(user_value.to_s.gsub(",", "."))
127
- comparison_value_float = Float(comparison_value.to_s.gsub(",", "."))
128
- if (comparator == 10 && user_value_float == comparison_value_float) ||
129
- (comparator == 11 && user_value_float != comparison_value_float) ||
130
- (comparator == 12 && user_value_float < comparison_value_float) ||
131
- (comparator == 13 && user_value_float <= comparison_value_float) ||
132
- (comparator == 14 && user_value_float > comparison_value_float) ||
133
- (comparator == 15 && user_value_float >= comparison_value_float)
134
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
135
- return value, variation_id, rollout_rule, nil, nil
136
- end
137
- rescue Exception => e
138
- message = format_validation_error_rule(comparison_attribute, user_value, comparator, comparison_value, e.to_s)
139
- @log.warn(0, message)
140
- log_entries.push(message)
141
- next
77
+ end
78
+
79
+ # Evaluate percentage options of the targeting rule
80
+ log_builder&.increase_indent
81
+ percentage_options = targeting_rule.fetch(TARGETING_RULE_PERCENTAGE_OPTIONS, [])
82
+ percentage_evaluation_result, percentage_value, percentage_variation_id, percentage_option =
83
+ evaluate_percentage_options(percentage_options, context, percentage_rule_attribute,
84
+ default_variation_id, log_builder)
85
+
86
+ if percentage_evaluation_result
87
+ if log_builder
88
+ log_builder.decrease_indent
89
+ log_builder.new_line("Returning '#{percentage_value}'.") if is_root_flag_evaluation
142
90
  end
143
- # IS ONE OF (Sensitive)
144
- elsif comparator == 16
145
- if comparison_value.to_s.split(",").map { |x| x.strip() }.include?(Digest::SHA1.hexdigest(user_value).to_s)
146
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
147
- return value, variation_id, rollout_rule, nil, nil
91
+ return [percentage_value, percentage_variation_id, targeting_rule, percentage_option, nil]
92
+ else
93
+ if log_builder
94
+ log_builder.new_line('The current targeting rule is ignored and the evaluation continues with the next rule.')
95
+ log_builder.decrease_indent
148
96
  end
149
- # IS NOT ONE OF (Sensitive)
150
- elsif comparator == 17
151
- if !comparison_value.to_s.split(",").map { |x| x.strip() }.include?(Digest::SHA1.hexdigest(user_value).to_s)
152
- log_entries.push(format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value))
153
- return value, variation_id, rollout_rule, nil, nil
97
+ next
98
+ end
99
+ end
100
+
101
+ # Evaluate percentage options
102
+ percentage_options = setting_descriptor.fetch(PERCENTAGE_OPTIONS, [])
103
+ percentage_evaluation_result, percentage_value, percentage_variation_id, percentage_option =
104
+ evaluate_percentage_options(percentage_options, context, percentage_rule_attribute,
105
+ default_variation_id, log_builder)
106
+
107
+ if percentage_evaluation_result
108
+ log_builder.new_line("Returning '#{percentage_value}'.") if log_builder && is_root_flag_evaluation
109
+ return [percentage_value, percentage_variation_id, nil, percentage_option, nil]
110
+ end
111
+
112
+ return_value = Config.get_value(setting_descriptor, setting_type)
113
+ return_variation_id = setting_descriptor.fetch(VARIATION_ID, default_variation_id)
114
+ log_builder.new_line("Returning '#{return_value}'.") if log_builder && is_root_flag_evaluation
115
+ return [return_value, return_variation_id, nil, nil, nil]
116
+ rescue => e
117
+ # During the recursive evaluation of a prerequisite flag, we propagate the exceptions
118
+ # and let the root flag's evaluation code handle them.
119
+ if !is_root_flag_evaluation
120
+ raise e
121
+ else
122
+ error = "Failed to evaluate setting '#{key}'. (#{e}). " \
123
+ "Returning the `%s` parameter that you specified in your application: '#{default_value}'."
124
+ @log.error(2001, error)
125
+ return [default_value, default_variation_id, nil, nil, error]
126
+ end
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ # Calculates the SHA256 hash of the given value with the given salt and context_salt.
133
+ def sha256(value_utf8, salt, context_salt)
134
+ Digest::SHA256.hexdigest(value_utf8 + salt + context_salt)
135
+ end
136
+
137
+ def format_rule(comparison_attribute, comparator, comparison_value)
138
+ comparator_text = COMPARATOR_TEXTS[comparator]
139
+ "User.#{comparison_attribute} #{comparator_text} #{EvaluationLogBuilder.trunc_comparison_value_if_needed(comparator, comparison_value)}"
140
+ end
141
+
142
+ def user_attribute_value_to_string(value)
143
+ return nil if value.nil?
144
+
145
+ if value.is_a?(DateTime) || value.is_a?(Time)
146
+ value = get_user_attribute_value_as_seconds_since_epoch(value)
147
+ elsif Utils.is_string_list(value)
148
+ value = get_user_attribute_value_as_string_list(value)
149
+ return value.to_json # Convert the array to a JSON string
150
+ end
151
+
152
+ if value.is_a?(Float)
153
+ return 'NaN' if value.nan?
154
+ return 'Infinity' if value.infinite? == 1
155
+ return '-Infinity' if value.infinite? == -1
156
+ return value.to_s if value.to_s.include?('e')
157
+ return value.to_i.to_s if value == value.to_i
158
+ end
159
+
160
+ value.to_s
161
+ end
162
+
163
+ def get_user_attribute_value_as_text(attribute_name, attribute_value, condition, key)
164
+ return attribute_value if attribute_value.is_a?(String)
165
+
166
+ @log.warn(3005, "Evaluation of condition (#{condition}) for setting '#{key}' may not produce the expected result " \
167
+ "(the User.#{attribute_name} attribute is not a string value, thus it was automatically converted to " \
168
+ "the string value '#{attribute_value}'). Please make sure that using a non-string value was intended.")
169
+ user_attribute_value_to_string(attribute_value)
170
+ end
171
+
172
+ def convert_numeric_to_float(value)
173
+ if value.is_a?(String)
174
+ value = value.tr(',', '.').strip
175
+ if value == 'NaN'
176
+ return Float::NAN
177
+ elsif value == 'Infinity'
178
+ return Float::INFINITY
179
+ elsif value == '-Infinity'
180
+ return -Float::INFINITY
181
+ end
182
+ end
183
+
184
+ Float(value)
185
+ end
186
+
187
+ def get_user_attribute_value_as_seconds_since_epoch(attribute_value)
188
+ if attribute_value.is_a?(DateTime) || attribute_value.is_a?(Time)
189
+ return Utils.get_seconds_since_epoch(attribute_value)
190
+ end
191
+
192
+ convert_numeric_to_float(attribute_value)
193
+ end
194
+
195
+ def get_user_attribute_value_as_string_list(attribute_value)
196
+ if attribute_value.is_a?(String)
197
+ attribute_value_list = JSON.parse(attribute_value)
198
+ else
199
+ attribute_value_list = attribute_value
200
+ end
201
+
202
+ raise "All items in the list must be strings" unless Utils.is_string_list(attribute_value_list)
203
+
204
+ attribute_value_list
205
+ end
206
+
207
+ # :returns evaluation error message
208
+ def handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, validation_error)
209
+ error = "cannot evaluate, the User.#{comparison_attribute} attribute is invalid (#{validation_error})"
210
+ formatted_rule = format_rule(comparison_attribute, comparator, comparison_value)
211
+ @log.warn(3004, "Cannot evaluate condition (#{formatted_rule}) for setting '#{key}' " \
212
+ "(#{validation_error}). Please check the User.#{comparison_attribute} attribute and make sure that its value corresponds to the " \
213
+ "comparison operator.")
214
+ error
215
+ end
216
+
217
+ # :returns evaluation_result, percentage_value, percentage_variation_id, percentage_option
218
+ def evaluate_percentage_options(percentage_options, context, percentage_rule_attribute, default_variation_id, log_builder)
219
+ return [false, nil, nil, nil] if percentage_options.empty?
220
+
221
+ user = context.user
222
+ key = context.key
223
+
224
+ if user.nil?
225
+ unless context.is_missing_user_object_logged
226
+ @log.warn(3001, "Cannot evaluate targeting rules and % options for setting '#{key}' " \
227
+ "(User Object is missing). " \
228
+ "You should pass a User Object to the evaluation methods like `get_value()` " \
229
+ "in order to make targeting work properly. " \
230
+ "Read more: https://configcat.com/docs/advanced/user-object/")
231
+ context.is_missing_user_object_logged = true
232
+ end
233
+
234
+ log_builder&.new_line('Skipping % options because the User Object is missing.')
235
+ return [false, nil, nil, nil]
236
+ end
237
+
238
+ user_attribute_name = percentage_rule_attribute || 'Identifier'
239
+ if percentage_rule_attribute
240
+ user_key = user.get_attribute(percentage_rule_attribute)
241
+ else
242
+ user_key = user.get_identifier
243
+ end
244
+
245
+ if percentage_rule_attribute && user_key.nil?
246
+ unless context.is_missing_user_object_attribute_logged
247
+ @log.warn(3003, "Cannot evaluate % options for setting '#{key}' " \
248
+ "(the User.#{percentage_rule_attribute} attribute is missing). You should set the User.#{percentage_rule_attribute} attribute in order to make " \
249
+ "targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/")
250
+ context.is_missing_user_object_attribute_logged = true
251
+ end
252
+
253
+ log_builder&.new_line("Skipping % options because the User.#{user_attribute_name} attribute is missing.")
254
+ return [false, nil, nil, nil]
255
+ end
256
+
257
+ hash_candidate = "#{key}#{user_attribute_value_to_string(user_key)}".encode("utf-8")
258
+ hash_val = Digest::SHA1.hexdigest(hash_candidate)[0...7].to_i(16) % 100
259
+
260
+ if log_builder
261
+ log_builder.new_line("Evaluating % options based on the User.#{user_attribute_name} attribute:")
262
+ log_builder.new_line("- Computing hash in the [0..99] range from User.#{user_attribute_name} => #{hash_val} " \
263
+ "(this value is sticky and consistent across all SDKs)")
264
+ end
265
+
266
+ bucket = 0
267
+ index = 1
268
+ percentage_options.each do |percentage_option|
269
+ percentage = percentage_option[PERCENTAGE] || 0
270
+ bucket += percentage
271
+ if hash_val < bucket
272
+ percentage_value = Config.get_value(percentage_option, context.setting_type)
273
+ variation_id = percentage_option[VARIATION_ID] || default_variation_id
274
+ if log_builder
275
+ log_builder.new_line("- Hash value #{hash_val} selects % option #{index} (#{percentage}%), '#{percentage_value}'.")
276
+ end
277
+ return [true, percentage_value, variation_id, percentage_option]
278
+ end
279
+ index += 1
280
+ end
281
+
282
+ [false, nil, nil, nil]
283
+ end
284
+
285
+ def evaluate_conditions(conditions, context, salt, config, log_builder, value)
286
+ first_condition = true
287
+ condition_result = true
288
+ error = nil
289
+
290
+ conditions.each do |condition|
291
+ user_condition = condition[USER_CONDITION]
292
+ segment_condition = condition[SEGMENT_CONDITION]
293
+ prerequisite_flag_condition = condition[PREREQUISITE_FLAG_CONDITION]
294
+
295
+ if first_condition
296
+ log_builder&.new_line('- IF ')
297
+ log_builder&.increase_indent
298
+ first_condition = false
299
+ else
300
+ log_builder&.new_line('AND ')
301
+ end
302
+
303
+ if user_condition
304
+ result, error = evaluate_user_condition(user_condition, context, context.key, salt, log_builder)
305
+ if log_builder && conditions.size > 1
306
+ log_builder.append("=> #{result ? 'true' : 'false'}")
307
+ log_builder.append(', skipping the remaining AND conditions') unless result
308
+ end
309
+
310
+ if !result || error
311
+ condition_result = false
312
+ break
313
+ end
314
+ elsif segment_condition
315
+ result, error = evaluate_segment_condition(segment_condition, context, salt, log_builder)
316
+ if log_builder
317
+ if conditions.size > 1
318
+ log_builder.append(' ') if error.nil?
319
+ log_builder.append("=> #{result ? 'true' : 'false'}")
320
+ log_builder.append(', skipping the remaining AND conditions') unless result
321
+ elsif error.nil?
322
+ log_builder.new_line
154
323
  end
155
324
  end
156
- log_entries.push(format_no_match_rule(comparison_attribute, user_value, comparator, comparison_value))
157
- end
158
-
159
- if rollout_percentage_items.size > 0
160
- user_key = user.get_identifier()
161
- hash_candidate = ("%s%s" % [key, user_key]).encode("utf-8")
162
- hash_val = Digest::SHA1.hexdigest(hash_candidate)[0...7].to_i(base = 16) % 100
163
- bucket = 0
164
- for rollout_percentage_item in rollout_percentage_items || []
165
- bucket += rollout_percentage_item.fetch(PERCENTAGE, 0)
166
- if hash_val < bucket
167
- percentage_value = rollout_percentage_item.fetch(VALUE, nil)
168
- variation_id = rollout_percentage_item.fetch(VARIATION_ID, default_variation_id)
169
- log_entries.push("Evaluating %% options. Returning %s" % percentage_value)
170
- return percentage_value, variation_id, nil, rollout_percentage_item, nil
325
+
326
+ if !result || error
327
+ condition_result = false
328
+ break
329
+ end
330
+ elsif prerequisite_flag_condition
331
+ result = evaluate_prerequisite_flag_condition(prerequisite_flag_condition, context, config, log_builder)
332
+ if log_builder
333
+ if conditions.size > 1
334
+ log_builder.append(" => #{result ? 'true' : 'false'}")
335
+ log_builder.append(', skipping the remaining AND conditions') unless result
336
+ elsif error.nil?
337
+ log_builder.new_line
171
338
  end
172
339
  end
340
+
341
+ if !result
342
+ condition_result = false
343
+ break
344
+ end
173
345
  end
174
- return_value = setting_descriptor.fetch(VALUE, default_value)
175
- return_variation_id = setting_descriptor.fetch(VARIATION_ID, default_variation_id)
176
- log_entries.push("Returning %s" % return_value)
177
- return return_value, return_variation_id, nil, nil, nil
178
- ensure
179
- @log.info(5000, log_entries.join("\n"))
180
346
  end
347
+
348
+ if log_builder
349
+ log_builder.new_line if conditions.size > 1
350
+ if error
351
+ log_builder.append("THEN #{value ? "'#{value}'" : '% options'} => #{error}")
352
+ log_builder.new_line("The current targeting rule is ignored and the evaluation continues with the next rule.")
353
+ else
354
+ log_builder.append("THEN #{value ? "'#{value}'" : "% options"} => #{condition_result ? "MATCH, applying rule" : "no match"}")
355
+ end
356
+ log_builder.decrease_indent if conditions.size > 0
357
+ end
358
+
359
+ condition_result
181
360
  end
182
361
 
183
- private
362
+ def evaluate_prerequisite_flag_condition(prerequisite_flag_condition, context, config, log_builder)
363
+ prerequisite_key = prerequisite_flag_condition[PREREQUISITE_FLAG_KEY]
364
+ prerequisite_comparator = prerequisite_flag_condition[PREREQUISITE_COMPARATOR]
365
+
366
+ # Check if the prerequisite key exists
367
+ settings = config.fetch(FEATURE_FLAGS, {})
368
+ if prerequisite_key.nil? || settings[prerequisite_key].nil?
369
+ raise "Prerequisite flag key is missing or invalid."
370
+ end
371
+
372
+ prerequisite_condition_result = false
373
+ prerequisite_flag_setting_type = settings[prerequisite_key][SETTING_TYPE]
374
+ prerequisite_comparison_value_type = Config.get_value_type(prerequisite_flag_condition)
375
+
376
+ prerequisite_comparison_value = Config.get_value(prerequisite_flag_condition, prerequisite_flag_setting_type)
377
+
378
+ # Type mismatch check
379
+ if prerequisite_comparison_value_type != SettingType.to_type(prerequisite_flag_setting_type)
380
+ raise "Type mismatch between comparison value '#{prerequisite_comparison_value}' and prerequisite flag '#{prerequisite_key}'"
381
+ end
382
+
383
+ prerequisite_condition = "Flag '#{prerequisite_key}' #{PREREQUISITE_COMPARATOR_TEXTS[prerequisite_comparator]} '#{prerequisite_comparison_value}'"
384
+
385
+ # Circular dependency check
386
+ visited_keys = context.visited_keys
387
+ visited_keys.push(context.key)
388
+ if visited_keys.include?(prerequisite_key)
389
+ depending_flags = visited_keys.push(prerequisite_key).map { |s| "'#{s}'" }.join(' -> ')
390
+ raise "Circular dependency detected between the following depending flags: #{depending_flags}."
391
+ end
392
+
393
+ if log_builder
394
+ log_builder.append(prerequisite_condition)
395
+ log_builder.new_line('(').increase_indent
396
+ log_builder.new_line("Evaluating prerequisite flag '#{prerequisite_key}':")
397
+ end
398
+
399
+ prerequisite_value, _, _, _, _ = evaluate(key: prerequisite_key, user: context.user, default_value: nil, default_variation_id: nil,
400
+ config: config, log_builder: log_builder, visited_keys: context.visited_keys)
401
+
402
+ visited_keys.pop
403
+
404
+ if log_builder
405
+ log_builder.new_line("Prerequisite flag evaluation result: '#{prerequisite_value}'.")
406
+ log_builder.new_line("Condition (Flag '#{prerequisite_key}' #{PREREQUISITE_COMPARATOR_TEXTS[prerequisite_comparator]} '#{prerequisite_comparison_value}') evaluates to ")
407
+ end
408
+
409
+ if prerequisite_comparator == PrerequisiteComparator::EQUALS
410
+ prerequisite_condition_result = true if prerequisite_value == prerequisite_comparison_value
411
+ elsif prerequisite_comparator == PrerequisiteComparator::NOT_EQUALS
412
+ prerequisite_condition_result = true if prerequisite_value != prerequisite_comparison_value
413
+ else
414
+ raise "Comparison operator is missing or invalid."
415
+ end
416
+
417
+ if log_builder
418
+ log_builder.append("#{prerequisite_condition_result ? 'true' : 'false'}.")
419
+ log_builder.decrease_indent()&.new_line(')')
420
+ end
184
421
 
185
- def format_match_rule(comparison_attribute, user_value, comparator, comparison_value, value)
186
- return "Evaluating rule: [%s:%s] [%s] [%s] => match, returning: %s" % [comparison_attribute, user_value, COMPARATOR_TEXTS[comparator], comparison_value, value]
422
+ prerequisite_condition_result
187
423
  end
188
424
 
189
- def format_no_match_rule(comparison_attribute, user_value, comparator, comparison_value)
190
- return "Evaluating rule: [%s:%s] [%s] [%s] => no match" % [comparison_attribute, user_value, COMPARATOR_TEXTS[comparator], comparison_value]
425
+ def evaluate_segment_condition(segment_condition, context, salt, log_builder)
426
+ user = context.user
427
+ key = context.key
428
+
429
+ segment = segment_condition[INLINE_SEGMENT]
430
+ if segment.nil?
431
+ raise 'Segment reference is invalid.'
432
+ end
433
+
434
+ segment_name = segment.fetch(SEGMENT_NAME, '')
435
+ segment_comparator = segment_condition[SEGMENT_COMPARATOR]
436
+ segment_conditions = segment.fetch(SEGMENT_CONDITIONS, [])
437
+
438
+ if user.nil?
439
+ unless context.is_missing_user_object_logged
440
+ @log.warn(3001, "Cannot evaluate targeting rules and % options for setting '#{key}' " \
441
+ "(User Object is missing). " \
442
+ "You should pass a User Object to the evaluation methods like `get_value()` " \
443
+ "in order to make targeting work properly. " \
444
+ "Read more: https://configcat.com/docs/advanced/user-object/")
445
+ context.is_missing_user_object_logged = true
446
+ end
447
+ log_builder&.append("User #{SEGMENT_COMPARATOR_TEXTS[segment_comparator]} '#{segment_name}' ")
448
+ return [false, "cannot evaluate, User Object is missing"]
449
+ end
450
+
451
+ # IS IN SEGMENT, IS NOT IN SEGMENT
452
+ if [SegmentComparator::IS_IN, SegmentComparator::IS_NOT_IN].include?(segment_comparator)
453
+ if log_builder
454
+ log_builder.append("User #{SEGMENT_COMPARATOR_TEXTS[segment_comparator]} '#{segment_name}'")
455
+ log_builder.new_line("(").increase_indent
456
+ log_builder.new_line("Evaluating segment '#{segment_name}':")
457
+ end
458
+
459
+ # Set initial condition result based on comparator
460
+ segment_condition_result = segment_comparator == SegmentComparator::IS_IN
461
+
462
+ # Evaluate segment conditions (logically connected by AND)
463
+ first_segment_rule = true
464
+ error = nil
465
+ segment_conditions.each do |segment_condition|
466
+ if first_segment_rule
467
+ if log_builder
468
+ log_builder.new_line('- IF ')
469
+ log_builder.increase_indent
470
+ end
471
+ first_segment_rule = false
472
+ else
473
+ log_builder&.new_line('AND ')
474
+ end
475
+
476
+ result, error = evaluate_user_condition(segment_condition, context, segment_name, salt, log_builder)
477
+ if log_builder
478
+ log_builder.append("=> #{result ? 'true' : 'false'}")
479
+ log_builder.append(', skipping the remaining AND conditions') unless result
480
+ end
481
+
482
+ unless result
483
+ segment_condition_result = segment_comparator == SegmentComparator::IS_IN ? false : true
484
+ break
485
+ end
486
+ end
487
+
488
+ if log_builder
489
+ log_builder.decrease_indent
490
+ segment_evaluation_result = segment_comparator == SegmentComparator::IS_IN ? segment_condition_result : !segment_condition_result
491
+ log_builder.new_line("Segment evaluation result: ")
492
+ unless error
493
+ log_builder.append("User IS#{segment_evaluation_result ? ' ' : ' NOT '}IN SEGMENT.")
494
+ else
495
+ log_builder.append("#{error}.")
496
+ end
497
+
498
+ log_builder.new_line("Condition (User #{SEGMENT_COMPARATOR_TEXTS[segment_comparator]} '#{segment_name}') ")
499
+
500
+ unless error
501
+ log_builder.append("evaluates to #{segment_condition_result ? 'true' : 'false'}.")
502
+ else
503
+ log_builder.append("failed to evaluate.")
504
+ end
505
+
506
+ log_builder.decrease_indent.new_line(')')
507
+ log_builder.new_line if error
508
+ end
509
+
510
+ return [segment_condition_result, error]
511
+ end
512
+
513
+ raise "Comparison operator is missing or invalid."
191
514
  end
192
515
 
193
- def format_validation_error_rule(comparison_attribute, user_value, comparator, comparison_value, error)
194
- return "Evaluating rule: [%s:%s] [%s] [%s] => SKIP rule. Validation error: %s" % [comparison_attribute, user_value, COMPARATOR_TEXTS[comparator], comparison_value, error]
516
+ # :returns result of user condition, error
517
+ def evaluate_user_condition(user_condition, context, context_salt, salt, log_builder)
518
+ user = context.user
519
+ key = context.key
520
+
521
+ comparison_attribute = user_condition[COMPARISON_ATTRIBUTE]
522
+ comparator = user_condition[COMPARATOR]
523
+ comparison_value = user_condition[COMPARISON_VALUES[comparator]]
524
+ condition = format_rule(comparison_attribute, comparator, comparison_value)
525
+ error = nil
526
+
527
+ if comparison_attribute.nil?
528
+ raise "Comparison attribute name is missing."
529
+ end
530
+
531
+ log_builder&.append("#{condition} ")
532
+
533
+ if user.nil?
534
+ unless context.is_missing_user_object_logged
535
+ @log.warn(3001, "Cannot evaluate targeting rules and % options for setting '#{key}' " \
536
+ "(User Object is missing). " \
537
+ "You should pass a User Object to the evaluation methods like `get_value()` " \
538
+ "in order to make targeting work properly. " \
539
+ "Read more: https://configcat.com/docs/advanced/user-object/")
540
+ context.is_missing_user_object_logged = true
541
+ end
542
+ error = "cannot evaluate, User Object is missing"
543
+ return [false, error]
544
+ end
545
+
546
+ user_value = user.get_attribute(comparison_attribute)
547
+ if user_value.nil? || (user_value.is_a?(String) && user_value.empty?)
548
+ @log.warn(3003, "Cannot evaluate condition (#{condition}) for setting '#{key}' " \
549
+ "(the User.#{comparison_attribute} attribute is missing). You should set the User.#{comparison_attribute} attribute in order to make " \
550
+ "targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/")
551
+ error = "cannot evaluate, the User.#{comparison_attribute} attribute is missing"
552
+ return [false, error]
553
+ end
554
+
555
+ # IS ONE OF
556
+ if comparator == Comparator::IS_ONE_OF
557
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
558
+ return true, error if comparison_value.include?(user_value)
559
+ # IS NOT ONE OF
560
+ elsif comparator == Comparator::IS_NOT_ONE_OF
561
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
562
+ return true, error unless comparison_value.include?(user_value)
563
+ # CONTAINS ANY OF
564
+ elsif comparator == Comparator::CONTAINS_ANY_OF
565
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
566
+ comparison_value.each do |comparison|
567
+ return true, error if user_value.include?(comparison)
568
+ end
569
+ # NOT CONTAINS ANY OF
570
+ elsif comparator == Comparator::NOT_CONTAINS_ANY_OF
571
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
572
+ return true, error unless comparison_value.any? { |comparison| user_value.include?(comparison) }
573
+ # IS ONE OF, IS NOT ONE OF (Semantic version)
574
+ elsif comparator >= Comparator::IS_ONE_OF_SEMVER && comparator <= Comparator::IS_NOT_ONE_OF_SEMVER
575
+ begin
576
+ match = false
577
+ user_value_version = Semantic::Version.new(user_value.to_s.strip())
578
+ ((comparison_value.map { |x| x.strip() }).reject { |c| c.empty? }).each { |x|
579
+ version = Semantic::Version.new(x)
580
+ match = (user_value_version == version) || match
581
+ }
582
+ if match && comparator == Comparator::IS_ONE_OF_SEMVER || !match && comparator == Comparator::IS_NOT_ONE_OF_SEMVER
583
+ return true, error
584
+ end
585
+ rescue ArgumentError => e
586
+ validation_error = "'#{user_value.to_s.strip}' is not a valid semantic version"
587
+ error = handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, validation_error)
588
+ return false, error
589
+ end
590
+ # LESS THAN, LESS THAN OR EQUAL TO, GREATER THAN, GREATER THAN OR EQUAL TO (Semantic version)
591
+ elsif comparator >= Comparator::LESS_THAN_SEMVER && comparator <= Comparator::GREATER_THAN_OR_EQUAL_SEMVER
592
+ begin
593
+ user_value_version = Semantic::Version.new(user_value.to_s.strip)
594
+ comparison_value_version = Semantic::Version.new(comparison_value.to_s.strip)
595
+ if (comparator == Comparator::LESS_THAN_SEMVER && user_value_version < comparison_value_version) ||
596
+ (comparator == Comparator::LESS_THAN_OR_EQUAL_SEMVER && user_value_version <= comparison_value_version) ||
597
+ (comparator == Comparator::GREATER_THAN_SEMVER && user_value_version > comparison_value_version) ||
598
+ (comparator == Comparator::GREATER_THAN_OR_EQUAL_SEMVER && user_value_version >= comparison_value_version)
599
+ return true, error
600
+ end
601
+ rescue ArgumentError => e
602
+ validation_error = "'#{user_value.to_s.strip}' is not a valid semantic version"
603
+ error = handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, validation_error)
604
+ return false, error
605
+ end
606
+ # =, <>, <, <=, >, >= (number)
607
+ elsif comparator >= Comparator::EQUALS_NUMBER && comparator <= Comparator::GREATER_THAN_OR_EQUAL_NUMBER
608
+ begin
609
+ user_value_float = convert_numeric_to_float(user_value)
610
+ rescue Exception => e
611
+ validation_error = "'#{user_value}' is not a valid decimal number"
612
+ error = handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, validation_error)
613
+ return false, error
614
+ end
615
+
616
+ comparison_value_float = Float(comparison_value)
617
+ if (comparator == Comparator::EQUALS_NUMBER && user_value_float == comparison_value_float) ||
618
+ (comparator == Comparator::NOT_EQUALS_NUMBER && user_value_float != comparison_value_float) ||
619
+ (comparator == Comparator::LESS_THAN_NUMBER && user_value_float < comparison_value_float) ||
620
+ (comparator == Comparator::LESS_THAN_OR_EQUAL_NUMBER && user_value_float <= comparison_value_float) ||
621
+ (comparator == Comparator::GREATER_THAN_NUMBER && user_value_float > comparison_value_float) ||
622
+ (comparator == Comparator::GREATER_THAN_OR_EQUAL_NUMBER && user_value_float >= comparison_value_float)
623
+ return true, error
624
+ end
625
+ # IS ONE OF (hashed)
626
+ elsif comparator == Comparator::IS_ONE_OF_HASHED
627
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
628
+ if comparison_value.include?(sha256(user_value, salt, context_salt))
629
+ return true, error
630
+ end
631
+ # IS NOT ONE OF (hashed)
632
+ elsif comparator == Comparator::IS_NOT_ONE_OF_HASHED
633
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
634
+ unless comparison_value.include?(sha256(user_value, salt, context_salt))
635
+ return true, error
636
+ end
637
+ # BEFORE, AFTER (UTC datetime)
638
+ elsif comparator >= Comparator::BEFORE_DATETIME && comparator <= Comparator::AFTER_DATETIME
639
+ begin
640
+ user_value_float = get_user_attribute_value_as_seconds_since_epoch(user_value)
641
+ rescue ArgumentError => e
642
+ validation_error = "'#{user_value}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)"
643
+ error = handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, validation_error)
644
+ return false, error
645
+ end
646
+
647
+ comparison_value_float = Float(comparison_value)
648
+ if (comparator == Comparator::BEFORE_DATETIME && user_value_float < comparison_value_float) ||
649
+ (comparator == Comparator::AFTER_DATETIME && user_value_float > comparison_value_float)
650
+ return true, error
651
+ end
652
+ # EQUALS (hashed)
653
+ elsif comparator == Comparator::EQUALS_HASHED
654
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
655
+ if sha256(user_value, salt, context_salt) == comparison_value
656
+ return true, error
657
+ end
658
+ # NOT EQUALS (hashed)
659
+ elsif comparator == Comparator::NOT_EQUALS_HASHED
660
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
661
+ if sha256(user_value, salt, context_salt) != comparison_value
662
+ return true, error
663
+ end
664
+ # STARTS WITH ANY OF, NOT STARTS WITH ANY OF, ENDS WITH ANY OF, NOT ENDS WITH ANY OF (hashed)
665
+ elsif comparator >= Comparator::STARTS_WITH_ANY_OF_HASHED && comparator <= Comparator::NOT_ENDS_WITH_ANY_OF_HASHED
666
+ comparison_value.each do |comparison|
667
+ underscore_index = comparison.index('_')
668
+ length = comparison[0...underscore_index].to_i
669
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
670
+
671
+ if user_value.bytesize >= length
672
+ comparison_string = comparison[(underscore_index + 1)..-1]
673
+ if (comparator == Comparator::STARTS_WITH_ANY_OF_HASHED && sha256(user_value.byteslice(0...length), salt, context_salt) == comparison_string) ||
674
+ (comparator == Comparator::ENDS_WITH_ANY_OF_HASHED && sha256(user_value.byteslice(-length..-1), salt, context_salt) == comparison_string)
675
+ return true, error
676
+ elsif (comparator == Comparator::NOT_STARTS_WITH_ANY_OF_HASHED && sha256(user_value.byteslice(0...length), salt, context_salt) == comparison_string) ||
677
+ (comparator == Comparator::NOT_ENDS_WITH_ANY_OF_HASHED && sha256(user_value.byteslice(-length..-1), salt, context_salt) == comparison_string)
678
+ return false, nil
679
+ end
680
+ end
681
+ end
682
+
683
+ # If no matches were found for the NOT_* conditions, then return true
684
+ if [Comparator::NOT_STARTS_WITH_ANY_OF_HASHED, Comparator::NOT_ENDS_WITH_ANY_OF_HASHED].include?(comparator)
685
+ return true, error
686
+ end
687
+ # ARRAY CONTAINS ANY OF, ARRAY NOT CONTAINS ANY OF (hashed)
688
+ elsif comparator >= Comparator::ARRAY_CONTAINS_ANY_OF_HASHED && comparator <= Comparator::ARRAY_NOT_CONTAINS_ANY_OF_HASHED
689
+ begin
690
+ user_value_list = get_user_attribute_value_as_string_list(user_value)
691
+ rescue Exception
692
+ validation_error = "'#{user_value}' is not a valid string array"
693
+ error = handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, validation_error)
694
+ return false, error
695
+ end
696
+
697
+ hashed_user_values = user_value_list.map { |x| sha256(x, salt, context_salt) }
698
+ if comparator == Comparator::ARRAY_CONTAINS_ANY_OF_HASHED
699
+ comparison_value.each do |comparison|
700
+ return true, error if hashed_user_values.include?(comparison)
701
+ end
702
+ else
703
+ comparison_value.each do |comparison|
704
+ return false, nil if hashed_user_values.include?(comparison)
705
+ end
706
+ return true, error
707
+ end
708
+ # EQUALS
709
+ elsif comparator == Comparator::EQUALS
710
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
711
+ return true, error if user_value == comparison_value
712
+ # NOT EQUALS
713
+ elsif comparator == Comparator::NOT_EQUALS
714
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
715
+ return true, error if user_value != comparison_value
716
+ # STARTS WITH ANY OF, NOT STARTS WITH ANY OF, ENDS WITH ANY OF, NOT ENDS WITH ANY OF
717
+ elsif comparator >= Comparator::STARTS_WITH_ANY_OF && comparator <= Comparator::NOT_ENDS_WITH_ANY_OF
718
+ user_value = get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
719
+ comparison_value.each do |comparison|
720
+ if (comparator == Comparator::STARTS_WITH_ANY_OF && user_value.start_with?(comparison)) ||
721
+ (comparator == Comparator::ENDS_WITH_ANY_OF && user_value.end_with?(comparison))
722
+ return true, error
723
+ elsif (comparator == Comparator::NOT_STARTS_WITH_ANY_OF && user_value.start_with?(comparison)) ||
724
+ (comparator == Comparator::NOT_ENDS_WITH_ANY_OF && user_value.end_with?(comparison))
725
+ return false, nil
726
+ end
727
+ end
728
+
729
+ # If no matches were found for the NOT_* conditions, then return true
730
+ if [Comparator::NOT_STARTS_WITH_ANY_OF, Comparator::NOT_ENDS_WITH_ANY_OF].include?(comparator)
731
+ return true, error
732
+ end
733
+ # ARRAY CONTAINS ANY OF, ARRAY NOT CONTAINS ANY OF
734
+ elsif comparator >= Comparator::ARRAY_CONTAINS_ANY_OF && comparator <= Comparator::ARRAY_NOT_CONTAINS_ANY_OF
735
+ begin
736
+ user_value_list = get_user_attribute_value_as_string_list(user_value)
737
+ rescue Exception
738
+ validation_error = "'#{user_value}' is not a valid string array"
739
+ error = handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, validation_error)
740
+ return false, error
741
+ end
742
+
743
+ if comparator == Comparator::ARRAY_CONTAINS_ANY_OF
744
+ comparison_value.each do |comparison|
745
+ return true, error if user_value_list.include?(comparison)
746
+ end
747
+ else
748
+ comparison_value.each do |comparison|
749
+ return false, nil if user_value_list.include?(comparison)
750
+ end
751
+ return true, error
752
+ end
753
+ else
754
+ raise "Comparison operator is missing or invalid."
755
+ end
756
+
757
+ [false, nil]
195
758
  end
196
759
  end
197
760
  end