optimizely-sdk 5.0.0.pre.beta → 5.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,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