optimizely-sdk 5.0.0.pre.beta → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,542 +1,558 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright 2019-2022, Optimizely and contributors
4
- #
5
- # Licensed under the Apache License, Version 2.0 (the "License");
6
- # you may not use this file except in compliance with the License.
7
- # You may obtain a copy of the License at
8
- #
9
- # http://www.apache.org/licenses/LICENSE-2.0
10
- #
11
- # Unless required by applicable law or agreed to in writing, software
12
- # distributed under the License is distributed on an "AS IS" BASIS,
13
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
- # See the License for the specific language governing permissions and
15
- # limitations under the License.
16
- #
17
- require 'json'
18
- require 'optimizely/project_config'
19
- require 'optimizely/helpers/constants'
20
- require 'optimizely/helpers/validator'
21
- module Optimizely
22
- class DatafileProjectConfig < ProjectConfig
23
- # Representation of the Optimizely project config.
24
- RUNNING_EXPERIMENT_STATUS = ['Running'].freeze
25
- RESERVED_ATTRIBUTE_PREFIX = '$opt_'
26
-
27
- attr_reader :datafile, :account_id, :attributes, :audiences, :typed_audiences, :events,
28
- :experiments, :feature_flags, :groups, :project_id, :bot_filtering, :revision,
29
- :sdk_key, :environment_key, :rollouts, :version, :send_flag_decisions,
30
- :attribute_key_map, :audience_id_map, :event_key_map, :experiment_feature_map,
31
- :experiment_id_map, :experiment_key_map, :feature_flag_key_map, :feature_variable_key_map,
32
- :group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
33
- :variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
34
- :variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations,
35
- :public_key_for_odp, :host_for_odp, :all_segments
36
- # Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
37
- attr_reader :anonymize_ip
38
-
39
- def initialize(datafile, logger, error_handler)
40
- # ProjectConfig init method to fetch and set project config data
41
- #
42
- # datafile - JSON string representing the project
43
- super()
44
-
45
- config = JSON.parse(datafile)
46
-
47
- @datafile = datafile
48
- @error_handler = error_handler
49
- @logger = logger
50
- @version = config['version']
51
-
52
- raise InvalidDatafileVersionError, @version unless Helpers::Constants::SUPPORTED_VERSIONS.value?(@version)
53
-
54
- @account_id = config['accountId']
55
- @attributes = config.fetch('attributes', [])
56
- @audiences = config.fetch('audiences', [])
57
- @typed_audiences = config.fetch('typedAudiences', [])
58
- @events = config.fetch('events', [])
59
- @experiments = config['experiments']
60
- @feature_flags = config.fetch('featureFlags', [])
61
- @groups = config.fetch('groups', [])
62
- @project_id = config['projectId']
63
- @anonymize_ip = config.key?('anonymizeIP') ? config['anonymizeIP'] : false
64
- @bot_filtering = config['botFiltering']
65
- @revision = config['revision']
66
- @sdk_key = config.fetch('sdkKey', '')
67
- @environment_key = config.fetch('environmentKey', '')
68
- @rollouts = config.fetch('rollouts', [])
69
- @send_flag_decisions = config.fetch('sendFlagDecisions', false)
70
- @integrations = config.fetch('integrations', [])
71
-
72
- # Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
73
- # Converting it to a first-class json type while creating Project Config
74
- @feature_flags.each do |feature_flag|
75
- feature_flag['variables'].each do |variable|
76
- if variable['type'] == 'string' && variable['subType'] == 'json'
77
- variable['type'] = 'json'
78
- variable.delete('subType')
79
- end
80
- end
81
- end
82
-
83
- # Utility maps for quick lookup
84
- @attribute_key_map = generate_key_map(@attributes, 'key')
85
- @event_key_map = generate_key_map(@events, 'key')
86
- @group_id_map = generate_key_map(@groups, 'id')
87
- @group_id_map.each do |key, group|
88
- exps = group.fetch('experiments')
89
- exps.each do |exp|
90
- @experiments.push(exp.merge('groupId' => key))
91
- end
92
- end
93
- @experiment_key_map = generate_key_map(@experiments, 'key')
94
- @experiment_id_map = generate_key_map(@experiments, 'id')
95
- @audience_id_map = generate_key_map(@audiences, 'id')
96
- @integration_key_map = generate_key_map(@integrations, 'key', first_value: true)
97
- @audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
98
- @variation_id_map = {}
99
- @variation_key_map = {}
100
- @variation_id_map_by_experiment_id = {}
101
- @variation_key_map_by_experiment_id = {}
102
- @variation_id_to_variable_usage_map = {}
103
- @variation_id_to_experiment_map = {}
104
- @flag_variation_map = {}
105
-
106
- @experiment_id_map.each_value do |exp|
107
- # Excludes experiments from rollouts
108
- variations = exp.fetch('variations')
109
- variations.each do |variation|
110
- variation_id = variation['id']
111
- @variation_id_to_experiment_map[variation_id] = exp
112
- end
113
- end
114
- @rollout_id_map = generate_key_map(@rollouts, 'id')
115
- # split out the experiment key map for rollouts
116
- @rollout_experiment_id_map = {}
117
- @rollout_id_map.each_value do |rollout|
118
- exps = rollout.fetch('experiments')
119
- @rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
120
- end
121
-
122
- if (odp_integration = @integration_key_map&.fetch('odp', nil))
123
- @public_key_for_odp = odp_integration['publicKey']
124
- @host_for_odp = odp_integration['host']
125
- end
126
-
127
- @all_segments = []
128
- @audience_id_map.each_value do |audience|
129
- @all_segments.concat Audience.get_segments(audience['conditions'])
130
- end
131
-
132
- @flag_variation_map = generate_feature_variation_map(@feature_flags)
133
- @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
134
- @all_experiments.each do |id, exp|
135
- variations = exp.fetch('variations')
136
- variations.each do |variation|
137
- variation_id = variation['id']
138
- variation['featureEnabled'] = variation['featureEnabled'] == true
139
- variation_variables = variation['variables']
140
- next if variation_variables.nil?
141
-
142
- @variation_id_to_variable_usage_map[variation_id] = generate_key_map(variation_variables, 'id')
143
- end
144
- @variation_id_map[exp['key']] = generate_key_map(variations, 'id')
145
- @variation_key_map[exp['key']] = generate_key_map(variations, 'key')
146
- @variation_id_map_by_experiment_id[id] = generate_key_map(variations, 'id')
147
- @variation_key_map_by_experiment_id[id] = generate_key_map(variations, 'key')
148
- end
149
- @feature_flag_key_map = generate_key_map(@feature_flags, 'key')
150
- @experiment_feature_map = {}
151
- @feature_variable_key_map = {}
152
- @feature_flag_key_map.each do |key, feature_flag|
153
- @feature_variable_key_map[key] = generate_key_map(feature_flag['variables'], 'key')
154
- feature_flag['experimentIds'].each do |experiment_id|
155
- @experiment_feature_map[experiment_id] = [feature_flag['id']]
156
- end
157
- end
158
- end
159
-
160
- def get_rules_for_flag(feature_flag)
161
- # Retrieves rules for a given feature flag
162
- #
163
- # feature_flag - String key representing the feature_flag
164
- #
165
- # Returns rules in feature flag
166
- rules = feature_flag['experimentIds'].map { |exp_id| @experiment_id_map[exp_id] }
167
- rollout = feature_flag['rolloutId'].empty? ? nil : @rollout_id_map[feature_flag['rolloutId']]
168
-
169
- if rollout
170
- rollout_experiments = rollout.fetch('experiments')
171
- rollout_experiments.each do |exp|
172
- rules.push(exp)
173
- end
174
- end
175
- rules
176
- end
177
-
178
- def self.create(datafile, logger, error_handler, skip_json_validation)
179
- # Looks up and sets datafile and config based on response body.
180
- #
181
- # datafile - JSON string representing the Optimizely project.
182
- # logger - Provides a logger instance.
183
- # error_handler - Provides a handle_error method to handle exceptions.
184
- # skip_json_validation - Optional boolean param which allows skipping JSON schema
185
- # validation upon object invocation. By default JSON schema validation will be performed.
186
- # Returns instance of DatafileProjectConfig, nil otherwise.
187
- logger ||= SimpleLogger.new
188
- if !skip_json_validation && !Helpers::Validator.datafile_valid?(datafile)
189
- logger.log(Logger::ERROR, InvalidInputError.new('datafile').message)
190
- return nil
191
- end
192
-
193
- begin
194
- config = new(datafile, logger, error_handler)
195
- rescue StandardError => e
196
- error_to_handle = e.instance_of?(InvalidDatafileVersionError) ? e : InvalidInputError.new('datafile')
197
- error_msg = error_to_handle.message
198
-
199
- logger.log(Logger::ERROR, error_msg)
200
- error_handler.handle_error error_to_handle
201
- return nil
202
- end
203
-
204
- config
205
- end
206
-
207
- def experiment_running?(experiment)
208
- # Determine if experiment corresponding to given key is running
209
- #
210
- # experiment - Experiment
211
- #
212
- # Returns true if experiment is running
213
- RUNNING_EXPERIMENT_STATUS.include?(experiment['status'])
214
- end
215
-
216
- def get_experiment_from_key(experiment_key)
217
- # Retrieves experiment ID for a given key
218
- #
219
- # experiment_key - String key representing the experiment
220
- #
221
- # Returns Experiment or nil if not found
222
-
223
- experiment = @experiment_key_map[experiment_key]
224
- return experiment if experiment
225
-
226
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
227
- @error_handler.handle_error InvalidExperimentError
228
- nil
229
- end
230
-
231
- def get_experiment_from_id(experiment_id)
232
- # Retrieves experiment ID for a given key
233
- #
234
- # experiment_id - String id representing the experiment
235
- #
236
- # Returns Experiment or nil if not found
237
-
238
- experiment = @experiment_id_map[experiment_id]
239
- return experiment if experiment
240
-
241
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
242
- @error_handler.handle_error InvalidExperimentError
243
- nil
244
- end
245
-
246
- def get_experiment_key(experiment_id)
247
- # Retrieves experiment key for a given ID.
248
- #
249
- # experiment_id - String ID representing the experiment.
250
- #
251
- # Returns String key.
252
-
253
- experiment = @experiment_id_map[experiment_id]
254
- return experiment['key'] unless experiment.nil?
255
-
256
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
257
- @error_handler.handle_error InvalidExperimentError
258
- nil
259
- end
260
-
261
- def get_event_from_key(event_key)
262
- # Get event for the provided event key.
263
- #
264
- # event_key - Event key for which event is to be determined.
265
- #
266
- # Returns Event corresponding to the provided event key.
267
-
268
- event = @event_key_map[event_key]
269
- return event if event
270
-
271
- @logger.log Logger::ERROR, "Event '#{event_key}' is not in datafile."
272
- @error_handler.handle_error InvalidEventError
273
- nil
274
- end
275
-
276
- def get_audience_from_id(audience_id)
277
- # Get audience for the provided audience ID
278
- #
279
- # audience_id - ID of the audience
280
- #
281
- # Returns the audience
282
-
283
- audience = @audience_id_map[audience_id]
284
- return audience if audience
285
-
286
- @logger.log Logger::ERROR, "Audience '#{audience_id}' is not in datafile."
287
- @error_handler.handle_error InvalidAudienceError
288
- nil
289
- end
290
-
291
- def get_variation_from_flag(flag_key, target_value, attribute)
292
- variations = @flag_variation_map[flag_key]
293
- return variations.select { |variation| variation[attribute] == target_value }.first if variations
294
-
295
- nil
296
- end
297
-
298
- def get_variation_from_id(experiment_key, variation_id)
299
- # Get variation given experiment key and variation ID
300
- #
301
- # experiment_key - Key representing parent experiment of variation
302
- # variation_id - ID of the variation
303
- #
304
- # Returns the variation or nil if not found
305
-
306
- variation_id_map = @variation_id_map[experiment_key]
307
- if variation_id_map
308
- variation = variation_id_map[variation_id]
309
- return variation if variation
310
-
311
- @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
312
- @error_handler.handle_error InvalidVariationError
313
- return nil
314
- end
315
-
316
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
317
- @error_handler.handle_error InvalidExperimentError
318
- nil
319
- end
320
-
321
- def get_variation_from_id_by_experiment_id(experiment_id, variation_id)
322
- # Get variation given experiment ID and variation ID
323
- #
324
- # experiment_id - ID representing parent experiment of variation
325
- # variation_id - ID of the variation
326
- #
327
- # Returns the variation or nil if not found
328
-
329
- variation_id_map_by_experiment_id = @variation_id_map_by_experiment_id[experiment_id]
330
- if variation_id_map_by_experiment_id
331
- variation = variation_id_map_by_experiment_id[variation_id]
332
- return variation if variation
333
-
334
- @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
335
- @error_handler.handle_error InvalidVariationError
336
- return nil
337
- end
338
-
339
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
340
- @error_handler.handle_error InvalidExperimentError
341
- nil
342
- end
343
-
344
- def get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
345
- # Get variation given experiment ID and variation key
346
- #
347
- # experiment_id - ID representing parent experiment of variation
348
- # variation_key - Key of the variation
349
- #
350
- # Returns the variation or nil if not found
351
-
352
- variation_key_map = @variation_key_map_by_experiment_id[experiment_id]
353
- if variation_key_map
354
- variation = variation_key_map[variation_key]
355
- return variation['id'] if variation
356
-
357
- @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
358
- @error_handler.handle_error InvalidVariationError
359
- return nil
360
- end
361
-
362
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
363
- @error_handler.handle_error InvalidExperimentError
364
- nil
365
- end
366
-
367
- def get_variation_id_from_key(experiment_key, variation_key)
368
- # Get variation ID given experiment key and variation key
369
- #
370
- # experiment_key - Key representing parent experiment of variation
371
- # variation_key - Key of the variation
372
- #
373
- # Returns ID of the variation
374
-
375
- variation_key_map = @variation_key_map[experiment_key]
376
- if variation_key_map
377
- variation = variation_key_map[variation_key]
378
- return variation['id'] if variation
379
-
380
- @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
381
- @error_handler.handle_error InvalidVariationError
382
- return nil
383
- end
384
-
385
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
386
- @error_handler.handle_error InvalidExperimentError
387
- nil
388
- end
389
-
390
- def get_whitelisted_variations(experiment_id)
391
- # Retrieves whitelisted variations for a given experiment id
392
- #
393
- # experiment_id - String id representing the experiment
394
- #
395
- # Returns whitelisted variations for the experiment or nil
396
-
397
- experiment = @experiment_id_map[experiment_id]
398
- return experiment['forcedVariations'] if experiment
399
-
400
- @logger.log Logger::ERROR, "Experiment ID '#{experiment_id}' is not in datafile."
401
- @error_handler.handle_error InvalidExperimentError
402
- end
403
-
404
- def get_attribute_id(attribute_key)
405
- # Get attribute ID for the provided attribute key.
406
- #
407
- # Args:
408
- # Attribute key for which attribute is to be fetched.
409
- #
410
- # Returns:
411
- # Attribute ID corresponding to the provided attribute key.
412
- attribute = @attribute_key_map[attribute_key]
413
- has_reserved_prefix = attribute_key.to_s.start_with?(RESERVED_ATTRIBUTE_PREFIX)
414
- unless attribute.nil?
415
- if has_reserved_prefix
416
- @logger.log(Logger::WARN, "Attribute '#{attribute_key}' unexpectedly has reserved prefix '#{RESERVED_ATTRIBUTE_PREFIX}'; "\
417
- 'using attribute ID instead of reserved attribute name.')
418
- end
419
- return attribute['id']
420
- end
421
- return attribute_key if has_reserved_prefix
422
-
423
- @logger.log Logger::ERROR, "Attribute key '#{attribute_key}' is not in datafile."
424
- @error_handler.handle_error InvalidAttributeError
425
- nil
426
- end
427
-
428
- def variation_id_exists?(experiment_id, variation_id)
429
- # Determines if a given experiment ID / variation ID pair exists in the datafile
430
- #
431
- # experiment_id - String experiment ID
432
- # variation_id - String variation ID
433
- #
434
- # Returns true if variation is in datafile
435
-
436
- experiment_key = get_experiment_key(experiment_id)
437
- variation_id_map = @variation_id_map[experiment_key]
438
- if variation_id_map
439
- variation = variation_id_map[variation_id]
440
- return true if variation
441
-
442
- @logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile."
443
- @error_handler.handle_error InvalidVariationError
444
- end
445
-
446
- false
447
- end
448
-
449
- def get_feature_flag_from_key(feature_flag_key)
450
- # Retrieves the feature flag with the given key
451
- #
452
- # feature_flag_key - String feature key
453
- #
454
- # Returns feature flag if found, otherwise nil
455
- feature_flag = @feature_flag_key_map[feature_flag_key]
456
- return feature_flag if feature_flag
457
-
458
- @logger.log Logger::ERROR, "Feature flag key '#{feature_flag_key}' is not in datafile."
459
- nil
460
- end
461
-
462
- def get_feature_variable(feature_flag, variable_key)
463
- # Retrieves the variable with the given key for the given feature
464
- #
465
- # feature_flag - The feature flag for which we are retrieving the variable
466
- # variable_key - String variable key
467
- #
468
- # Returns variable if found, otherwise nil
469
- feature_flag_key = feature_flag['key']
470
- variable = @feature_variable_key_map[feature_flag_key][variable_key]
471
- return variable if variable
472
-
473
- @logger.log Logger::ERROR, "No feature variable was found for key '#{variable_key}' in feature flag "\
474
- "'#{feature_flag_key}'."
475
- nil
476
- end
477
-
478
- def get_rollout_from_id(rollout_id)
479
- # Retrieves the rollout with the given ID
480
- #
481
- # rollout_id - String rollout ID
482
- #
483
- # Returns the rollout if found, otherwise nil
484
- rollout = @rollout_id_map[rollout_id]
485
- return rollout if rollout
486
-
487
- @logger.log Logger::ERROR, "Rollout with ID '#{rollout_id}' is not in the datafile."
488
- nil
489
- end
490
-
491
- def feature_experiment?(experiment_id)
492
- # Determines if given experiment is a feature test.
493
- #
494
- # experiment_id - String experiment ID
495
- #
496
- # Returns true if experiment belongs to any feature,
497
- # false otherwise.
498
- @experiment_feature_map.key?(experiment_id)
499
- end
500
-
501
- def rollout_experiment?(experiment_id)
502
- # Determines if given experiment is a rollout test.
503
- #
504
- # experiment_id - String experiment ID
505
- #
506
- # Returns true if experiment belongs to any rollout,
507
- # false otherwise.
508
- @rollout_experiment_id_map.key?(experiment_id)
509
- end
510
-
511
- private
512
-
513
- def generate_feature_variation_map(feature_flags)
514
- flag_variation_map = {}
515
- feature_flags.each do |flag|
516
- variations = []
517
- get_rules_for_flag(flag).each do |rule|
518
- rule['variations'].each do |rule_variation|
519
- variations.push(rule_variation) if variations.select { |variation| variation['id'] == rule_variation['id'] }.empty?
520
- end
521
- end
522
- flag_variation_map[flag['key']] = variations
523
- end
524
- flag_variation_map
525
- end
526
-
527
- def generate_key_map(array, key, first_value: false)
528
- # Helper method to generate map from key to hash in array of hashes
529
- #
530
- # array - Array consisting of hash
531
- # key - Key in each hash which will be key in the map
532
- # first_value - Determines which value to save if there are duplicate keys. By default the last instance of the key
533
- # will be saved. Set to true to save the first key/value encountered.
534
- #
535
- # Returns map mapping key to hash
536
-
537
- array
538
- .group_by { |obj| obj[key] }
539
- .transform_values { |group| first_value ? group.first : group.last }
540
- end
541
- end
542
- end
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019-2022, Optimizely and contributors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ require 'json'
18
+ require 'optimizely/project_config'
19
+ require 'optimizely/helpers/constants'
20
+ require 'optimizely/helpers/validator'
21
+ module Optimizely
22
+ class DatafileProjectConfig < ProjectConfig
23
+ # Representation of the Optimizely project config.
24
+ RUNNING_EXPERIMENT_STATUS = ['Running'].freeze
25
+ RESERVED_ATTRIBUTE_PREFIX = '$opt_'
26
+
27
+ attr_reader :datafile, :account_id, :attributes, :audiences, :typed_audiences, :events,
28
+ :experiments, :feature_flags, :groups, :project_id, :bot_filtering, :revision,
29
+ :sdk_key, :environment_key, :rollouts, :version, :send_flag_decisions,
30
+ :attribute_key_map, :audience_id_map, :event_key_map, :experiment_feature_map,
31
+ :experiment_id_map, :experiment_key_map, :feature_flag_key_map, :feature_variable_key_map,
32
+ :group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
33
+ :variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
34
+ :variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations,
35
+ :public_key_for_odp, :host_for_odp, :all_segments
36
+ # Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
37
+ attr_reader :anonymize_ip
38
+
39
+ def initialize(datafile, logger, error_handler)
40
+ # ProjectConfig init method to fetch and set project config data
41
+ #
42
+ # datafile - JSON string representing the project
43
+ super()
44
+
45
+ config = JSON.parse(datafile)
46
+
47
+ @datafile = datafile
48
+ @error_handler = error_handler
49
+ @logger = logger
50
+ @version = config['version']
51
+
52
+ raise InvalidDatafileVersionError, @version unless Helpers::Constants::SUPPORTED_VERSIONS.value?(@version)
53
+
54
+ @account_id = config['accountId']
55
+ @attributes = config.fetch('attributes', [])
56
+ @audiences = config.fetch('audiences', [])
57
+ @typed_audiences = config.fetch('typedAudiences', [])
58
+ @events = config.fetch('events', [])
59
+ @experiments = config['experiments']
60
+ @feature_flags = config.fetch('featureFlags', [])
61
+ @groups = config.fetch('groups', [])
62
+ @project_id = config['projectId']
63
+ @anonymize_ip = config.key?('anonymizeIP') ? config['anonymizeIP'] : false
64
+ @bot_filtering = config['botFiltering']
65
+ @revision = config['revision']
66
+ @sdk_key = config.fetch('sdkKey', '')
67
+ @environment_key = config.fetch('environmentKey', '')
68
+ @rollouts = config.fetch('rollouts', [])
69
+ @send_flag_decisions = config.fetch('sendFlagDecisions', false)
70
+ @integrations = config.fetch('integrations', [])
71
+
72
+ # Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
73
+ # Converting it to a first-class json type while creating Project Config
74
+ @feature_flags.each do |feature_flag|
75
+ feature_flag['variables'].each do |variable|
76
+ if variable['type'] == 'string' && variable['subType'] == 'json'
77
+ variable['type'] = 'json'
78
+ variable.delete('subType')
79
+ end
80
+ end
81
+ end
82
+
83
+ # Utility maps for quick lookup
84
+ @attribute_key_map = generate_key_map(@attributes, 'key')
85
+ @event_key_map = generate_key_map(@events, 'key')
86
+ @group_id_map = generate_key_map(@groups, 'id')
87
+ @group_id_map.each do |key, group|
88
+ exps = group.fetch('experiments')
89
+ exps.each do |exp|
90
+ @experiments.push(exp.merge('groupId' => key))
91
+ end
92
+ end
93
+ @experiment_key_map = generate_key_map(@experiments, 'key')
94
+ @experiment_id_map = generate_key_map(@experiments, 'id')
95
+ @audience_id_map = generate_key_map(@audiences, 'id')
96
+ @integration_key_map = generate_key_map(@integrations, 'key', first_value: true)
97
+ @audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
98
+ @variation_id_map = {}
99
+ @variation_key_map = {}
100
+ @variation_id_map_by_experiment_id = {}
101
+ @variation_key_map_by_experiment_id = {}
102
+ @variation_id_to_variable_usage_map = {}
103
+ @variation_id_to_experiment_map = {}
104
+ @flag_variation_map = {}
105
+
106
+ @experiment_id_map.each_value do |exp|
107
+ # Excludes experiments from rollouts
108
+ variations = exp.fetch('variations')
109
+ variations.each do |variation|
110
+ variation_id = variation['id']
111
+ @variation_id_to_experiment_map[variation_id] = exp
112
+ end
113
+ end
114
+ @rollout_id_map = generate_key_map(@rollouts, 'id')
115
+ # split out the experiment key map for rollouts
116
+ @rollout_experiment_id_map = {}
117
+ @rollout_id_map.each_value do |rollout|
118
+ exps = rollout.fetch('experiments')
119
+ @rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
120
+ end
121
+
122
+ if (odp_integration = @integration_key_map&.fetch('odp', nil))
123
+ @public_key_for_odp = odp_integration['publicKey']
124
+ @host_for_odp = odp_integration['host']
125
+ end
126
+
127
+ @all_segments = []
128
+ @audience_id_map.each_value do |audience|
129
+ @all_segments.concat Audience.get_segments(audience['conditions'])
130
+ end
131
+
132
+ @flag_variation_map = generate_feature_variation_map(@feature_flags)
133
+ @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
134
+ @all_experiments.each do |id, exp|
135
+ variations = exp.fetch('variations')
136
+ variations.each do |variation|
137
+ variation_id = variation['id']
138
+ variation['featureEnabled'] = variation['featureEnabled'] == true
139
+ variation_variables = variation['variables']
140
+ next if variation_variables.nil?
141
+
142
+ @variation_id_to_variable_usage_map[variation_id] = generate_key_map(variation_variables, 'id')
143
+ end
144
+ @variation_id_map[exp['key']] = generate_key_map(variations, 'id')
145
+ @variation_key_map[exp['key']] = generate_key_map(variations, 'key')
146
+ @variation_id_map_by_experiment_id[id] = generate_key_map(variations, 'id')
147
+ @variation_key_map_by_experiment_id[id] = generate_key_map(variations, 'key')
148
+ end
149
+ @feature_flag_key_map = generate_key_map(@feature_flags, 'key')
150
+ @experiment_feature_map = {}
151
+ @feature_variable_key_map = {}
152
+ @feature_flag_key_map.each do |key, feature_flag|
153
+ @feature_variable_key_map[key] = generate_key_map(feature_flag['variables'], 'key')
154
+ feature_flag['experimentIds'].each do |experiment_id|
155
+ @experiment_feature_map[experiment_id] = [feature_flag['id']]
156
+ end
157
+ end
158
+ end
159
+
160
+ def get_rules_for_flag(feature_flag)
161
+ # Retrieves rules for a given feature flag
162
+ #
163
+ # feature_flag - String key representing the feature_flag
164
+ #
165
+ # Returns rules in feature flag
166
+ rules = feature_flag['experimentIds'].map { |exp_id| @experiment_id_map[exp_id] }
167
+ rollout = feature_flag['rolloutId'].empty? ? nil : @rollout_id_map[feature_flag['rolloutId']]
168
+
169
+ if rollout
170
+ rollout_experiments = rollout.fetch('experiments')
171
+ rollout_experiments.each do |exp|
172
+ rules.push(exp)
173
+ end
174
+ end
175
+ rules
176
+ end
177
+
178
+ def self.create(datafile, logger, error_handler, skip_json_validation)
179
+ # Looks up and sets datafile and config based on response body.
180
+ #
181
+ # datafile - JSON string representing the Optimizely project.
182
+ # logger - Provides a logger instance.
183
+ # error_handler - Provides a handle_error method to handle exceptions.
184
+ # skip_json_validation - Optional boolean param which allows skipping JSON schema
185
+ # validation upon object invocation. By default JSON schema validation will be performed.
186
+ # Returns instance of DatafileProjectConfig, nil otherwise.
187
+ logger ||= SimpleLogger.new
188
+ if !skip_json_validation && !Helpers::Validator.datafile_valid?(datafile)
189
+ logger.log(Logger::ERROR, InvalidInputError.new('datafile').message)
190
+ return nil
191
+ end
192
+
193
+ begin
194
+ config = new(datafile, logger, error_handler)
195
+ rescue StandardError => e
196
+ error_to_handle = e.instance_of?(InvalidDatafileVersionError) ? e : InvalidInputError.new('datafile')
197
+ error_msg = error_to_handle.message
198
+
199
+ logger.log(Logger::ERROR, error_msg)
200
+ error_handler.handle_error error_to_handle
201
+ return nil
202
+ end
203
+
204
+ config
205
+ end
206
+
207
+ def experiment_running?(experiment)
208
+ # Determine if experiment corresponding to given key is running
209
+ #
210
+ # experiment - Experiment
211
+ #
212
+ # Returns true if experiment is running
213
+ RUNNING_EXPERIMENT_STATUS.include?(experiment['status'])
214
+ end
215
+
216
+ def get_experiment_from_key(experiment_key)
217
+ # Retrieves experiment ID for a given key
218
+ #
219
+ # experiment_key - String key representing the experiment
220
+ #
221
+ # Returns Experiment or nil if not found
222
+
223
+ experiment = @experiment_key_map[experiment_key]
224
+ return experiment if experiment
225
+
226
+ invalid_experiment_error = InvalidExperimentError.new(experiment_key: experiment_key)
227
+ @logger.log Logger::ERROR, invalid_experiment_error.message
228
+ @error_handler.handle_error invalid_experiment_error
229
+ nil
230
+ end
231
+
232
+ def get_experiment_from_id(experiment_id)
233
+ # Retrieves experiment ID for a given key
234
+ #
235
+ # experiment_id - String id representing the experiment
236
+ #
237
+ # Returns Experiment or nil if not found
238
+
239
+ experiment = @experiment_id_map[experiment_id]
240
+ return experiment if experiment
241
+
242
+ invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id)
243
+ @logger.log Logger::ERROR, invalid_experiment_error.message
244
+ @error_handler.handle_error invalid_experiment_error
245
+ nil
246
+ end
247
+
248
+ def get_experiment_key(experiment_id)
249
+ # Retrieves experiment key for a given ID.
250
+ #
251
+ # experiment_id - String ID representing the experiment.
252
+ #
253
+ # Returns String key.
254
+
255
+ experiment = @experiment_id_map[experiment_id]
256
+ return experiment['key'] unless experiment.nil?
257
+
258
+ invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id)
259
+ @logger.log Logger::ERROR, invalid_experiment_error.message
260
+ @error_handler.handle_error invalid_experiment_error
261
+ nil
262
+ end
263
+
264
+ def get_event_from_key(event_key)
265
+ # Get event for the provided event key.
266
+ #
267
+ # event_key - Event key for which event is to be determined.
268
+ #
269
+ # Returns Event corresponding to the provided event key.
270
+
271
+ event = @event_key_map[event_key]
272
+ return event if event
273
+
274
+ invalid_event_error = InvalidEventError.new(event_key)
275
+ @logger.log Logger::ERROR, invalid_event_error.message
276
+ @error_handler.handle_error invalid_event_error
277
+ nil
278
+ end
279
+
280
+ def get_audience_from_id(audience_id)
281
+ # Get audience for the provided audience ID
282
+ #
283
+ # audience_id - ID of the audience
284
+ #
285
+ # Returns the audience
286
+
287
+ audience = @audience_id_map[audience_id]
288
+ return audience if audience
289
+
290
+ invalid_audience_error = InvalidAudienceError.new(audience_id)
291
+ @logger.log Logger::ERROR, invalid_audience_error.message
292
+ @error_handler.handle_error invalid_audience_error
293
+ nil
294
+ end
295
+
296
+ def get_variation_from_flag(flag_key, target_value, attribute)
297
+ variations = @flag_variation_map[flag_key]
298
+ return variations.select { |variation| variation[attribute] == target_value }.first if variations
299
+
300
+ nil
301
+ end
302
+
303
+ def get_variation_from_id(experiment_key, variation_id)
304
+ # Get variation given experiment key and variation ID
305
+ #
306
+ # experiment_key - Key representing parent experiment of variation
307
+ # variation_id - ID of the variation
308
+ #
309
+ # Returns the variation or nil if not found
310
+
311
+ variation_id_map = @variation_id_map[experiment_key]
312
+ if variation_id_map
313
+ variation = variation_id_map[variation_id]
314
+ return variation if variation
315
+
316
+ invalid_variation_error = InvalidVariationError.new(variation_id: variation_id)
317
+ @logger.log Logger::ERROR, invalid_variation_error.message
318
+ @error_handler.handle_error invalid_variation_error
319
+ return nil
320
+ end
321
+
322
+ invalid_experiment_error = InvalidExperimentError.new(experiment_key: experiment_key)
323
+ @logger.log Logger::ERROR, invalid_experiment_error.message
324
+ @error_handler.handle_error invalid_experiment_error
325
+ nil
326
+ end
327
+
328
+ def get_variation_from_id_by_experiment_id(experiment_id, variation_id)
329
+ # Get variation given experiment ID and variation ID
330
+ #
331
+ # experiment_id - ID representing parent experiment of variation
332
+ # variation_id - ID of the variation
333
+ #
334
+ # Returns the variation or nil if not found
335
+
336
+ variation_id_map_by_experiment_id = @variation_id_map_by_experiment_id[experiment_id]
337
+ if variation_id_map_by_experiment_id
338
+ variation = variation_id_map_by_experiment_id[variation_id]
339
+ return variation if variation
340
+
341
+ invalid_variation_error = InvalidVariationError.new(variation_id: variation_id)
342
+ @logger.log Logger::ERROR, invalid_variation_error.message
343
+ @error_handler.handle_error invalid_variation_error
344
+ return nil
345
+ end
346
+
347
+ invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id)
348
+ @logger.log Logger::ERROR, invalid_experiment_error.message
349
+ @error_handler.handle_error invalid_experiment_error
350
+ nil
351
+ end
352
+
353
+ def get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
354
+ # Get variation given experiment ID and variation key
355
+ #
356
+ # experiment_id - ID representing parent experiment of variation
357
+ # variation_key - Key of the variation
358
+ #
359
+ # Returns the variation or nil if not found
360
+
361
+ variation_key_map = @variation_key_map_by_experiment_id[experiment_id]
362
+ if variation_key_map
363
+ variation = variation_key_map[variation_key]
364
+ return variation['id'] if variation
365
+
366
+ invalid_variation_error = InvalidVariationError.new(variation_key: variation_key)
367
+ @logger.log Logger::ERROR, invalid_variation_error.message
368
+ @error_handler.handle_error invalid_variation_error
369
+ return nil
370
+ end
371
+
372
+ invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id)
373
+ @logger.log Logger::ERROR, invalid_experiment_error.message
374
+ @error_handler.handle_error invalid_experiment_error
375
+ nil
376
+ end
377
+
378
+ def get_variation_id_from_key(experiment_key, variation_key)
379
+ # Get variation ID given experiment key and variation key
380
+ #
381
+ # experiment_key - Key representing parent experiment of variation
382
+ # variation_key - Key of the variation
383
+ #
384
+ # Returns ID of the variation
385
+
386
+ variation_key_map = @variation_key_map[experiment_key]
387
+ if variation_key_map
388
+ variation = variation_key_map[variation_key]
389
+ return variation['id'] if variation
390
+
391
+ invalid_variation_error = InvalidVariationError.new(variation_key: variation_key)
392
+ @logger.log Logger::ERROR, invalid_variation_error.message
393
+ @error_handler.handle_error invalid_variation_error
394
+ return nil
395
+ end
396
+
397
+ invalid_experiment_error = InvalidExperimentError.new(experiment_key: experiment_key)
398
+ @logger.log Logger::ERROR, invalid_experiment_error.message
399
+ @error_handler.handle_error invalid_experiment_error
400
+ nil
401
+ end
402
+
403
+ def get_whitelisted_variations(experiment_id)
404
+ # Retrieves whitelisted variations for a given experiment id
405
+ #
406
+ # experiment_id - String id representing the experiment
407
+ #
408
+ # Returns whitelisted variations for the experiment or nil
409
+
410
+ experiment = @experiment_id_map[experiment_id]
411
+ return experiment['forcedVariations'] if experiment
412
+
413
+ invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id)
414
+ @logger.log Logger::ERROR, invalid_experiment_error.message
415
+ @error_handler.handle_error invalid_experiment_error
416
+ end
417
+
418
+ def get_attribute_id(attribute_key)
419
+ # Get attribute ID for the provided attribute key.
420
+ #
421
+ # Args:
422
+ # Attribute key for which attribute is to be fetched.
423
+ #
424
+ # Returns:
425
+ # Attribute ID corresponding to the provided attribute key.
426
+ attribute = @attribute_key_map[attribute_key]
427
+ has_reserved_prefix = attribute_key.to_s.start_with?(RESERVED_ATTRIBUTE_PREFIX)
428
+ unless attribute.nil?
429
+ if has_reserved_prefix
430
+ @logger.log(Logger::WARN, "Attribute '#{attribute_key}' unexpectedly has reserved prefix '#{RESERVED_ATTRIBUTE_PREFIX}'; "\
431
+ 'using attribute ID instead of reserved attribute name.')
432
+ end
433
+ return attribute['id']
434
+ end
435
+ return attribute_key if has_reserved_prefix
436
+
437
+ invalid_attribute_error = InvalidAttributeError.new(attribute_key)
438
+ @logger.log Logger::ERROR, invalid_attribute_error.message
439
+ @error_handler.handle_error invalid_attribute_error
440
+ nil
441
+ end
442
+
443
+ def variation_id_exists?(experiment_id, variation_id)
444
+ # Determines if a given experiment ID / variation ID pair exists in the datafile
445
+ #
446
+ # experiment_id - String experiment ID
447
+ # variation_id - String variation ID
448
+ #
449
+ # Returns true if variation is in datafile
450
+
451
+ experiment_key = get_experiment_key(experiment_id)
452
+ variation_id_map = @variation_id_map[experiment_key]
453
+ if variation_id_map
454
+ variation = variation_id_map[variation_id]
455
+ return true if variation
456
+
457
+ invalid_variation_error = InvalidVariationError.new(variation_id: variation_id)
458
+ @logger.log Logger::ERROR, invalid_variation_error.message
459
+ @error_handler.handle_error invalid_variation_error
460
+ end
461
+
462
+ false
463
+ end
464
+
465
+ def get_feature_flag_from_key(feature_flag_key)
466
+ # Retrieves the feature flag with the given key
467
+ #
468
+ # feature_flag_key - String feature key
469
+ #
470
+ # Returns feature flag if found, otherwise nil
471
+ feature_flag = @feature_flag_key_map[feature_flag_key]
472
+ return feature_flag if feature_flag
473
+
474
+ @logger.log Logger::ERROR, "Feature flag key '#{feature_flag_key}' is not in datafile."
475
+ nil
476
+ end
477
+
478
+ def get_feature_variable(feature_flag, variable_key)
479
+ # Retrieves the variable with the given key for the given feature
480
+ #
481
+ # feature_flag - The feature flag for which we are retrieving the variable
482
+ # variable_key - String variable key
483
+ #
484
+ # Returns variable if found, otherwise nil
485
+ feature_flag_key = feature_flag['key']
486
+ variable = @feature_variable_key_map[feature_flag_key][variable_key]
487
+ return variable if variable
488
+
489
+ @logger.log Logger::ERROR, "No feature variable was found for key '#{variable_key}' in feature flag "\
490
+ "'#{feature_flag_key}'."
491
+ nil
492
+ end
493
+
494
+ def get_rollout_from_id(rollout_id)
495
+ # Retrieves the rollout with the given ID
496
+ #
497
+ # rollout_id - String rollout ID
498
+ #
499
+ # Returns the rollout if found, otherwise nil
500
+ rollout = @rollout_id_map[rollout_id]
501
+ return rollout if rollout
502
+
503
+ @logger.log Logger::ERROR, "Rollout with ID '#{rollout_id}' is not in the datafile."
504
+ nil
505
+ end
506
+
507
+ def feature_experiment?(experiment_id)
508
+ # Determines if given experiment is a feature test.
509
+ #
510
+ # experiment_id - String experiment ID
511
+ #
512
+ # Returns true if experiment belongs to any feature,
513
+ # false otherwise.
514
+ @experiment_feature_map.key?(experiment_id)
515
+ end
516
+
517
+ def rollout_experiment?(experiment_id)
518
+ # Determines if given experiment is a rollout test.
519
+ #
520
+ # experiment_id - String experiment ID
521
+ #
522
+ # Returns true if experiment belongs to any rollout,
523
+ # false otherwise.
524
+ @rollout_experiment_id_map.key?(experiment_id)
525
+ end
526
+
527
+ private
528
+
529
+ def generate_feature_variation_map(feature_flags)
530
+ flag_variation_map = {}
531
+ feature_flags.each do |flag|
532
+ variations = []
533
+ get_rules_for_flag(flag).each do |rule|
534
+ rule['variations'].each do |rule_variation|
535
+ variations.push(rule_variation) if variations.select { |variation| variation['id'] == rule_variation['id'] }.empty?
536
+ end
537
+ end
538
+ flag_variation_map[flag['key']] = variations
539
+ end
540
+ flag_variation_map
541
+ end
542
+
543
+ def generate_key_map(array, key, first_value: false)
544
+ # Helper method to generate map from key to hash in array of hashes
545
+ #
546
+ # array - Array consisting of hash
547
+ # key - Key in each hash which will be key in the map
548
+ # first_value - Determines which value to save if there are duplicate keys. By default the last instance of the key
549
+ # will be saved. Set to true to save the first key/value encountered.
550
+ #
551
+ # Returns map mapping key to hash
552
+
553
+ array
554
+ .group_by { |obj| obj[key] }
555
+ .transform_values { |group| first_value ? group.first : group.last }
556
+ end
557
+ end
558
+ end