configcat 7.0.0 → 8.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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