growthbook 0.3.0 → 1.1.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.
- 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(
|