growthbook 0.3.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/growthbook/conditions.rb +15 -14
- data/lib/growthbook/context.rb +172 -50
- data/lib/growthbook/decryption_util.rb +35 -0
- data/lib/growthbook/feature.rb +4 -3
- data/lib/growthbook/feature_repository.rb +87 -0
- data/lib/growthbook/feature_result.rb +3 -2
- data/lib/growthbook/feature_rule.rb +95 -33
- data/lib/growthbook/fnv.rb +23 -0
- data/lib/growthbook/inline_experiment.rb +71 -48
- data/lib/growthbook/inline_experiment_result.rb +57 -38
- data/lib/growthbook/tracking_callback.rb +8 -0
- data/lib/growthbook/util.rb +42 -49
- data/lib/growthbook.rb +5 -5
- metadata +56 -26
- data/lib/growthbook/client.rb +0 -67
- data/lib/growthbook/experiment.rb +0 -72
- data/lib/growthbook/experiment_result.rb +0 -43
- data/lib/growthbook/lookup_result.rb +0 -44
- data/lib/growthbook/user.rb +0 -165
- data/spec/cases.json +0 -2923
- data/spec/client_spec.rb +0 -57
- data/spec/context_spec.rb +0 -124
- data/spec/json_spec.rb +0 -160
- data/spec/user_spec.rb +0 -213
- data/spec/util_spec.rb +0 -154
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f5919b4bd1e6564233d0fb61f00f2fcc5fee175f3122a35e9815df47ebdaeb6f
|
4
|
+
data.tar.gz: 88bf0372e161e261a536fd8953f858e9f4ad60592a96cbfb8e3baa523414cbca
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 771a1f4fd4eeb6acb92606e67677d65717e3b3a98de80528d2adef3cce17f183a6524af1da74cb09ff18d195a062dd1c882c208cb8dfeaf631603c54b7d128e9
|
7
|
+
data.tar.gz: 3b2386b97c64ea3609ad907005d90baec0a25e7b51d45bbd973490d66a204255d5a31d1d435da406cfcd8f3443a28bb8163e6f649af114999b847cbe822f2593
|
@@ -3,6 +3,8 @@
|
|
3
3
|
require 'json'
|
4
4
|
|
5
5
|
module Growthbook
|
6
|
+
# internal use only
|
7
|
+
# Utils for condition evaluation
|
6
8
|
class Conditions
|
7
9
|
# Evaluate a targeting conditions hash against an attributes hash
|
8
10
|
# Both attributes and conditions only have string keys (no symbols)
|
@@ -25,9 +27,8 @@ module Growthbook
|
|
25
27
|
when Array
|
26
28
|
return condition.map { |v| parse_condition(v) }
|
27
29
|
when Hash
|
28
|
-
return condition.
|
30
|
+
return condition.to_h { |k, v| [k.to_s, parse_condition(v)] }
|
29
31
|
end
|
30
|
-
|
31
32
|
condition
|
32
33
|
end
|
33
34
|
|
@@ -47,7 +48,7 @@ module Growthbook
|
|
47
48
|
true
|
48
49
|
end
|
49
50
|
|
50
|
-
def self.
|
51
|
+
def self.operator_object?(obj)
|
51
52
|
obj.each do |key, _value|
|
52
53
|
return false if key[0] != '$'
|
53
54
|
end
|
@@ -58,7 +59,7 @@ module Growthbook
|
|
58
59
|
return 'string' if attribute_value.is_a? String
|
59
60
|
return 'number' if attribute_value.is_a? Integer
|
60
61
|
return 'number' if attribute_value.is_a? Float
|
61
|
-
return 'boolean' if
|
62
|
+
return 'boolean' if [true, false].include?(attribute_value)
|
62
63
|
return 'array' if attribute_value.is_a? Array
|
63
64
|
return 'null' if attribute_value.nil?
|
64
65
|
|
@@ -66,22 +67,22 @@ module Growthbook
|
|
66
67
|
end
|
67
68
|
|
68
69
|
def self.get_path(attributes, path)
|
70
|
+
path = path.to_s if path.is_a?(Symbol)
|
71
|
+
|
69
72
|
parts = path.split('.')
|
70
73
|
current = attributes
|
71
74
|
|
72
75
|
parts.each do |value|
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
return nil
|
77
|
-
end
|
76
|
+
return nil unless current.is_a?(Hash) && current&.key?(value)
|
77
|
+
|
78
|
+
current = current[value]
|
78
79
|
end
|
79
80
|
|
80
81
|
current
|
81
82
|
end
|
82
83
|
|
83
84
|
def self.eval_condition_value(condition_value, attribute_value)
|
84
|
-
if condition_value.is_a?(Hash) &&
|
85
|
+
if condition_value.is_a?(Hash) && operator_object?(condition_value)
|
85
86
|
condition_value.each do |key, value|
|
86
87
|
return false unless eval_operator_condition(key, attribute_value, value)
|
87
88
|
end
|
@@ -94,7 +95,7 @@ module Growthbook
|
|
94
95
|
return false unless attribute_value.is_a? Array
|
95
96
|
|
96
97
|
attribute_value.each do |item|
|
97
|
-
if
|
98
|
+
if operator_object?(condition)
|
98
99
|
return true if eval_condition_value(condition, item)
|
99
100
|
elsif eval_condition(item, condition)
|
100
101
|
return true
|
@@ -147,10 +148,10 @@ module Growthbook
|
|
147
148
|
true
|
148
149
|
when '$exists'
|
149
150
|
exists = !attribute_value.nil?
|
150
|
-
if
|
151
|
-
!exists
|
152
|
-
else
|
151
|
+
if condition_value
|
153
152
|
exists
|
153
|
+
else
|
154
|
+
!exists
|
154
155
|
end
|
155
156
|
when '$type'
|
156
157
|
condition_value == get_type(attribute_value)
|
data/lib/growthbook/context.rb
CHANGED
@@ -1,9 +1,34 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Growthbook
|
4
|
+
# Context object passed into the GrowthBook constructor.
|
4
5
|
class Context
|
5
|
-
|
6
|
-
|
6
|
+
# @return [true, false] Switch to globally disable all experiments. Default true.
|
7
|
+
attr_accessor :enabled
|
8
|
+
|
9
|
+
# @return [String] The URL of the current page
|
10
|
+
attr_accessor :url
|
11
|
+
|
12
|
+
# @return [true, false, nil] If true, random assignment is disabled and only explicitly forced variations are used.
|
13
|
+
attr_accessor :qa_mode
|
14
|
+
|
15
|
+
# @return [Growthbook::TrackingCallback] An object that responds to `on_experiment_viewed(GrowthBook::InlineExperiment, GrowthBook::InlineExperimentResult)`
|
16
|
+
attr_accessor :listener
|
17
|
+
|
18
|
+
# @return [Hash] Map of user attributes that are used to assign variations
|
19
|
+
attr_reader :attributes
|
20
|
+
|
21
|
+
# @return [Hash] Feature definitions (usually pulled from an API or cache)
|
22
|
+
attr_reader :features
|
23
|
+
|
24
|
+
# @return [Hash] Force specific experiments to always assign a specific variation (used for QA)
|
25
|
+
attr_reader :forced_variations
|
26
|
+
|
27
|
+
# @return [Hash[String, Growthbook::InlineExperimentResult]] Tracked impressions
|
28
|
+
attr_reader :impressions
|
29
|
+
|
30
|
+
# @return [Hash[String, Any]] Forced feature values
|
31
|
+
attr_reader :forced_features
|
7
32
|
|
8
33
|
def initialize(options = {})
|
9
34
|
@features = {}
|
@@ -13,14 +38,19 @@ module Growthbook
|
|
13
38
|
@enabled = true
|
14
39
|
@impressions = {}
|
15
40
|
|
16
|
-
options.each do |key, value|
|
17
|
-
case key
|
41
|
+
options.transform_keys(&:to_sym).each do |key, value|
|
42
|
+
case key
|
18
43
|
when :enabled
|
19
44
|
@enabled = value
|
20
45
|
when :attributes
|
21
46
|
self.attributes = value
|
22
47
|
when :url
|
23
48
|
@url = value
|
49
|
+
when :decryption_key
|
50
|
+
nil
|
51
|
+
when :encrypted_features
|
52
|
+
decrypted = decrypted_features_from_options(options)
|
53
|
+
self.features = decrypted unless decrypted.nil?
|
24
54
|
when :features
|
25
55
|
self.features = value
|
26
56
|
when :forced_variations, :forcedVariations
|
@@ -40,6 +70,8 @@ module Growthbook
|
|
40
70
|
def features=(features)
|
41
71
|
@features = {}
|
42
72
|
|
73
|
+
return if features.nil?
|
74
|
+
|
43
75
|
features.each do |k, v|
|
44
76
|
# Convert to a Feature object if it's not already
|
45
77
|
v = Growthbook::Feature.new(v) unless v.is_a? Growthbook::Feature
|
@@ -62,42 +94,49 @@ module Growthbook
|
|
62
94
|
|
63
95
|
def eval_feature(key)
|
64
96
|
# Forced in the context
|
65
|
-
if @forced_features.key?(key.to_s)
|
66
|
-
return get_feature_result(@forced_features[key.to_s], 'override')
|
67
|
-
end
|
97
|
+
return get_feature_result(@forced_features[key.to_s], 'override', nil, nil) if @forced_features.key?(key.to_s)
|
68
98
|
|
69
99
|
# Return if we can't find the feature definition
|
70
100
|
feature = get_feature(key)
|
71
|
-
return get_feature_result(nil, 'unknownFeature') unless feature
|
101
|
+
return get_feature_result(nil, 'unknownFeature', nil, nil) unless feature
|
72
102
|
|
73
103
|
feature.rules.each do |rule|
|
74
104
|
# Targeting condition
|
75
|
-
next if rule.condition && !condition_passes(rule.condition)
|
76
|
-
|
77
|
-
#
|
78
|
-
if rule.
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
105
|
+
next if rule.condition && !condition_passes?(rule.condition)
|
106
|
+
|
107
|
+
# If there are filters for who is included (e.g. namespaces)
|
108
|
+
next if rule.filters && filtered_out?(rule.filters || [])
|
109
|
+
|
110
|
+
# If this is a percentage rollout, skip if not included
|
111
|
+
if rule.force?
|
112
|
+
seed = rule.seed || key
|
113
|
+
hash_attribute = rule.hash_attribute || 'id'
|
114
|
+
included_in_rollout = included_in_rollout?(
|
115
|
+
seed: seed.to_s,
|
116
|
+
hash_attribute: hash_attribute,
|
117
|
+
range: rule.range,
|
118
|
+
coverage: rule.coverage,
|
119
|
+
hash_version: rule.hash_version
|
120
|
+
)
|
121
|
+
next unless included_in_rollout
|
122
|
+
|
123
|
+
return get_feature_result(rule.force, 'force', nil, nil)
|
87
124
|
end
|
88
125
|
# Experiment rule
|
89
|
-
next unless rule.
|
126
|
+
next unless rule.experiment?
|
90
127
|
|
91
128
|
exp = rule.to_experiment(key)
|
129
|
+
next if exp.nil?
|
130
|
+
|
92
131
|
result = _run(exp, key)
|
93
132
|
|
94
|
-
next unless result.in_experiment
|
133
|
+
next unless result.in_experiment && !result.passthrough
|
95
134
|
|
96
135
|
return get_feature_result(result.value, 'experiment', exp, result)
|
97
136
|
end
|
98
137
|
|
99
138
|
# Fallback
|
100
|
-
get_feature_result(feature.default_value
|
139
|
+
get_feature_result(feature.default_value.nil? ? nil : feature.default_value, 'defaultValue', nil, nil)
|
101
140
|
end
|
102
141
|
|
103
142
|
def run(exp)
|
@@ -119,58 +158,80 @@ module Growthbook
|
|
119
158
|
|
120
159
|
private
|
121
160
|
|
122
|
-
def _run(exp, feature_id=
|
161
|
+
def _run(exp, feature_id = '')
|
123
162
|
key = exp.key
|
124
163
|
|
125
164
|
# 1. If experiment doesn't have enough variations, return immediately
|
126
|
-
return get_experiment_result(exp, -1, false, feature_id) if exp.variations.length < 2
|
165
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if exp.variations.length < 2
|
127
166
|
|
128
167
|
# 2. If context is disabled, return immediately
|
129
|
-
return get_experiment_result(exp, -1, false, feature_id) unless @enabled
|
168
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless @enabled
|
130
169
|
|
131
170
|
# 3. If forced via URL querystring
|
132
|
-
|
133
|
-
|
134
|
-
|
171
|
+
override_url = @url
|
172
|
+
unless override_url.nil?
|
173
|
+
qs_override = Util.get_query_string_override(key, override_url, exp.variations.length)
|
174
|
+
return get_experiment_result(exp, qs_override, hash_used: false, feature_id: feature_id) unless qs_override.nil?
|
135
175
|
end
|
136
176
|
|
137
177
|
# 4. If variation is forced in the context, return the forced value
|
138
|
-
|
178
|
+
if @forced_variations.key?(key.to_s)
|
179
|
+
return get_experiment_result(
|
180
|
+
exp,
|
181
|
+
@forced_variations[key.to_s],
|
182
|
+
hash_used: false,
|
183
|
+
feature_id: feature_id
|
184
|
+
)
|
185
|
+
end
|
139
186
|
|
140
187
|
# 5. Exclude if not active
|
141
|
-
return get_experiment_result(exp, -1, false, feature_id) unless exp.active
|
188
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless exp.active
|
142
189
|
|
143
190
|
# 6. Get hash_attribute/value and return if empty
|
144
191
|
hash_attribute = exp.hash_attribute || 'id'
|
145
192
|
hash_value = get_attribute(hash_attribute).to_s
|
146
|
-
return get_experiment_result(exp, -1, false, feature_id) if hash_value.
|
193
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if hash_value.empty?
|
147
194
|
|
148
|
-
# 7. Exclude if user
|
149
|
-
|
195
|
+
# 7. Exclude if user is filtered out (used to be called "namespace")
|
196
|
+
if exp.filters
|
197
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if filtered_out?(exp.filters || [])
|
198
|
+
elsif exp.namespace && !Growthbook::Util.in_namespace?(hash_value, exp.namespace)
|
199
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id)
|
200
|
+
end
|
150
201
|
|
151
202
|
# 8. Exclude if condition is false
|
152
|
-
|
203
|
+
if exp.condition && !condition_passes?(exp.condition)
|
204
|
+
return get_experiment_result(
|
205
|
+
exp,
|
206
|
+
-1,
|
207
|
+
hash_used: false,
|
208
|
+
feature_id: feature_id
|
209
|
+
)
|
210
|
+
end
|
153
211
|
|
154
|
-
# 9.
|
155
|
-
ranges = Growthbook::Util.get_bucket_ranges(
|
212
|
+
# 9. Get bucket ranges and choose variation
|
213
|
+
ranges = exp.ranges || Growthbook::Util.get_bucket_ranges(
|
156
214
|
exp.variations.length,
|
157
215
|
exp.coverage,
|
158
216
|
exp.weights
|
159
217
|
)
|
160
|
-
|
218
|
+
seed = exp.seed || key || ''
|
219
|
+
n = Growthbook::Util.get_hash(seed: seed, value: hash_value, version: exp.hash_version || 1)
|
220
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if n.nil?
|
221
|
+
|
161
222
|
assigned = Growthbook::Util.choose_variation(n, ranges)
|
162
223
|
|
163
224
|
# 10. Return if not in experiment
|
164
|
-
return get_experiment_result(exp, -1, false, feature_id) if assigned.negative?
|
225
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if assigned.negative?
|
165
226
|
|
166
227
|
# 11. Experiment has a forced variation
|
167
|
-
return get_experiment_result(exp, exp.force, false, feature_id) unless exp.force.nil?
|
228
|
+
return get_experiment_result(exp, exp.force, hash_used: false, feature_id: feature_id) unless exp.force.nil?
|
168
229
|
|
169
230
|
# 12. Exclude if in QA mode
|
170
|
-
return get_experiment_result(exp, -1, false, feature_id) if @qa_mode
|
231
|
+
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if @qa_mode
|
171
232
|
|
172
233
|
# 13. Build the result object
|
173
|
-
result = get_experiment_result(exp, assigned, true, feature_id)
|
234
|
+
result = get_experiment_result(exp, assigned, hash_used: true, feature_id: feature_id, bucket: n)
|
174
235
|
|
175
236
|
# 14. Fire tracking callback
|
176
237
|
track_experiment(exp, result)
|
@@ -187,11 +248,13 @@ module Growthbook
|
|
187
248
|
new_hash
|
188
249
|
end
|
189
250
|
|
190
|
-
def condition_passes(condition)
|
251
|
+
def condition_passes?(condition)
|
252
|
+
return false if condition.nil?
|
253
|
+
|
191
254
|
Growthbook::Conditions.eval_condition(@attributes, condition)
|
192
255
|
end
|
193
256
|
|
194
|
-
def get_experiment_result(experiment, variation_index = -1, hash_used
|
257
|
+
def get_experiment_result(experiment, variation_index = -1, hash_used: false, feature_id: '', bucket: nil)
|
195
258
|
in_experiment = true
|
196
259
|
if variation_index.negative? || variation_index >= experiment.variations.length
|
197
260
|
variation_index = 0
|
@@ -200,12 +263,29 @@ module Growthbook
|
|
200
263
|
|
201
264
|
hash_attribute = experiment.hash_attribute || 'id'
|
202
265
|
hash_value = get_attribute(hash_attribute)
|
266
|
+
meta = experiment.meta ? experiment.meta[variation_index] : {}
|
267
|
+
|
268
|
+
result = Growthbook::InlineExperimentResult.new(
|
269
|
+
{
|
270
|
+
key: meta['key'] || variation_index,
|
271
|
+
in_experiment: in_experiment,
|
272
|
+
variation_id: variation_index,
|
273
|
+
value: experiment.variations[variation_index],
|
274
|
+
hash_used: hash_used,
|
275
|
+
hash_attribute: hash_attribute,
|
276
|
+
hash_value: hash_value,
|
277
|
+
feature_id: feature_id,
|
278
|
+
bucket: bucket,
|
279
|
+
name: meta['name']
|
280
|
+
}
|
281
|
+
)
|
282
|
+
|
283
|
+
result.passthrough = true if meta['passthrough']
|
203
284
|
|
204
|
-
|
205
|
-
experiment.variations[variation_index], hash_attribute, hash_value, feature_id)
|
285
|
+
result
|
206
286
|
end
|
207
287
|
|
208
|
-
def get_feature_result(value, source, experiment
|
288
|
+
def get_feature_result(value, source, experiment, experiment_result)
|
209
289
|
Growthbook::FeatureResult.new(value, source, experiment, experiment_result)
|
210
290
|
end
|
211
291
|
|
@@ -224,10 +304,52 @@ module Growthbook
|
|
224
304
|
end
|
225
305
|
|
226
306
|
def track_experiment(experiment, result)
|
227
|
-
if
|
228
|
-
|
307
|
+
return if listener.nil?
|
308
|
+
|
309
|
+
@listener.on_experiment_viewed(experiment, result) if @listener.respond_to?(:on_experiment_viewed)
|
310
|
+
@impressions[experiment.key] = result unless experiment.key.nil?
|
311
|
+
end
|
312
|
+
|
313
|
+
def included_in_rollout?(seed:, hash_attribute:, hash_version:, range:, coverage:)
|
314
|
+
return true if range.nil? && coverage.nil?
|
315
|
+
|
316
|
+
hash_value = get_attribute(hash_attribute)
|
317
|
+
|
318
|
+
return false if hash_value.empty?
|
319
|
+
|
320
|
+
n = Growthbook::Util.get_hash(seed: seed, value: hash_value, version: hash_version || 1)
|
321
|
+
return false if n.nil?
|
322
|
+
|
323
|
+
return Growthbook::Util.in_range?(n, range) if range
|
324
|
+
return n <= coverage if coverage
|
325
|
+
|
326
|
+
true
|
327
|
+
end
|
328
|
+
|
329
|
+
def filtered_out?(filters)
|
330
|
+
filters.any? do |filter|
|
331
|
+
hash_value = get_attribute(filter['attribute'] || 'id')
|
332
|
+
|
333
|
+
if hash_value.empty?
|
334
|
+
false
|
335
|
+
else
|
336
|
+
n = Growthbook::Util.get_hash(seed: filter['seed'] || '', value: hash_value, version: filter['hashVersion'] || 2)
|
337
|
+
|
338
|
+
return true if n.nil?
|
339
|
+
|
340
|
+
filter['ranges'].none? { |range| Growthbook::Util.in_range?(n, range) }
|
341
|
+
end
|
229
342
|
end
|
230
|
-
|
343
|
+
end
|
344
|
+
|
345
|
+
def decrypted_features_from_options(options)
|
346
|
+
decrypted_features = DecryptionUtil.decrypt(options[:encrypted_features], key: options[:decryption_key])
|
347
|
+
|
348
|
+
return nil if decrypted_features.nil?
|
349
|
+
|
350
|
+
JSON.parse(decrypted_features)
|
351
|
+
rescue StandardError
|
352
|
+
nil
|
231
353
|
end
|
232
354
|
end
|
233
355
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'openssl'
|
5
|
+
|
6
|
+
module Growthbook
|
7
|
+
# Utils for working with encrypted feature payloads.
|
8
|
+
class DecryptionUtil
|
9
|
+
# @return [String, nil] The decrypted payload, or nil if it fails to decrypt
|
10
|
+
def self.decrypt(payload, key:)
|
11
|
+
return nil if payload.nil?
|
12
|
+
return nil unless payload.include?('.')
|
13
|
+
|
14
|
+
parts = payload.split('.')
|
15
|
+
return nil if parts.length != 2
|
16
|
+
|
17
|
+
iv = parts[0]
|
18
|
+
decoded_iv = Base64.strict_decode64(iv)
|
19
|
+
decoded_key = Base64.strict_decode64(key)
|
20
|
+
|
21
|
+
cipher_text = parts[1]
|
22
|
+
decoded_cipher_text = Base64.strict_decode64(cipher_text)
|
23
|
+
|
24
|
+
cipher = OpenSSL::Cipher.new('aes-128-cbc')
|
25
|
+
|
26
|
+
cipher.decrypt
|
27
|
+
cipher.key = decoded_key
|
28
|
+
cipher.iv = decoded_iv
|
29
|
+
|
30
|
+
cipher.update(decoded_cipher_text) + cipher.final
|
31
|
+
rescue StandardError
|
32
|
+
nil
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/growthbook/feature.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Growthbook
|
4
|
+
# The feature with a generic value type.
|
4
5
|
class Feature
|
5
6
|
# @return [Any , nil]
|
6
7
|
attr_reader :default_value
|
@@ -9,9 +10,9 @@ module Growthbook
|
|
9
10
|
attr_reader :rules
|
10
11
|
|
11
12
|
def initialize(feature)
|
12
|
-
@default_value =
|
13
|
+
@default_value = get_option(feature, :defaultValue)
|
13
14
|
|
14
|
-
rules =
|
15
|
+
rules = get_option(feature, :rules)
|
15
16
|
|
16
17
|
@rules = []
|
17
18
|
rules&.each do |rule|
|
@@ -31,7 +32,7 @@ module Growthbook
|
|
31
32
|
|
32
33
|
private
|
33
34
|
|
34
|
-
def
|
35
|
+
def get_option(hash, key)
|
35
36
|
return hash[key.to_sym] if hash.key?(key.to_sym)
|
36
37
|
return hash[key.to_s] if hash.key?(key.to_s)
|
37
38
|
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
module Growthbook
|
7
|
+
# Optional class for fetching features from the GrowthBook API
|
8
|
+
class FeatureRepository
|
9
|
+
# [String] The SDK endpoint
|
10
|
+
attr_reader :endpoint
|
11
|
+
|
12
|
+
# [String, nil] Optional key for decrypting an encrypted payload
|
13
|
+
attr_reader :decryption_key
|
14
|
+
|
15
|
+
# Parsed features JSON
|
16
|
+
attr_reader :features_json
|
17
|
+
|
18
|
+
def initialize(endpoint:, decryption_key:)
|
19
|
+
@endpoint = endpoint
|
20
|
+
@decryption_key = decryption_key
|
21
|
+
@features_json = {}
|
22
|
+
end
|
23
|
+
|
24
|
+
def fetch
|
25
|
+
uri = URI(endpoint)
|
26
|
+
res = Net::HTTP.get_response(uri)
|
27
|
+
|
28
|
+
@response = res.is_a?(Net::HTTPSuccess) ? res.body : nil
|
29
|
+
|
30
|
+
return nil if response.nil?
|
31
|
+
|
32
|
+
if use_decryption?
|
33
|
+
parsed_decrypted_response
|
34
|
+
else
|
35
|
+
parsed_plain_text_response
|
36
|
+
end
|
37
|
+
rescue StandardError
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
|
41
|
+
def fetch!
|
42
|
+
fetch
|
43
|
+
|
44
|
+
raise FeatureFetchError if response.nil?
|
45
|
+
raise FeatureParseError if features_json.nil? || features_json.empty?
|
46
|
+
|
47
|
+
features_json
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :response
|
53
|
+
|
54
|
+
def use_decryption?
|
55
|
+
!decryption_key.nil?
|
56
|
+
end
|
57
|
+
|
58
|
+
def parsed_plain_text_response
|
59
|
+
@features_json = parsed_response['features'] unless parsed_response.nil?
|
60
|
+
rescue StandardError
|
61
|
+
nil
|
62
|
+
end
|
63
|
+
|
64
|
+
def parsed_decrypted_response
|
65
|
+
k = decryption_key
|
66
|
+
return nil if k.nil?
|
67
|
+
|
68
|
+
decrypted_str = Growthbook::DecryptionUtil.decrypt(parsed_response['encryptedFeatures'], key: k)
|
69
|
+
@features_json = JSON.parse(decrypted_str) unless decrypted_str.nil?
|
70
|
+
rescue StandardError
|
71
|
+
nil
|
72
|
+
end
|
73
|
+
|
74
|
+
def parsed_response
|
75
|
+
res = response
|
76
|
+
return {} if res.nil?
|
77
|
+
|
78
|
+
JSON.parse(res)
|
79
|
+
rescue StandardError
|
80
|
+
{}
|
81
|
+
end
|
82
|
+
|
83
|
+
class FeatureFetchError < StandardError; end
|
84
|
+
|
85
|
+
class FeatureParseError < StandardError; end
|
86
|
+
end
|
87
|
+
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Growthbook
|
4
|
+
# Result of a feature evaluation
|
4
5
|
class FeatureResult
|
5
6
|
# The assigned value of the feature
|
6
7
|
# @return [Any, nil]
|
@@ -19,11 +20,11 @@ module Growthbook
|
|
19
20
|
attr_reader :source
|
20
21
|
|
21
22
|
# The experiment used to decide the feature value
|
22
|
-
# @return [Growthbook
|
23
|
+
# @return [Growthbook::InlineExperiment, nil]
|
23
24
|
attr_reader :experiment
|
24
25
|
|
25
26
|
# The result of the experiment
|
26
|
-
# @return [Growthbook
|
27
|
+
# @return [Growthbook::InlineExperimentResult, nil]
|
27
28
|
attr_reader :experiment_result
|
28
29
|
|
29
30
|
def initialize(
|