featurehub-sdk 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +154 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +107 -0
- data/LICENSE.txt +21 -0
- data/Rakefile +12 -0
- data/featurehub-sdk.gemspec +45 -0
- data/featurehub-sdk.iml +83 -0
- data/lib/feature_hub/sdk/context.rb +191 -0
- data/lib/feature_hub/sdk/feature_hub_config.rb +125 -0
- data/lib/feature_hub/sdk/feature_repository.rb +110 -0
- data/lib/feature_hub/sdk/feature_state.rb +134 -0
- data/lib/feature_hub/sdk/impl/apply_features.rb +105 -0
- data/lib/feature_hub/sdk/impl/murmur3_percentage.rb +20 -0
- data/lib/feature_hub/sdk/impl/rollout_holders.rb +118 -0
- data/lib/feature_hub/sdk/impl/strategy_wrappers.rb +181 -0
- data/lib/feature_hub/sdk/interceptors.rb +54 -0
- data/lib/feature_hub/sdk/internal_feature_repository.rb +28 -0
- data/lib/feature_hub/sdk/percentage_calc.rb +12 -0
- data/lib/feature_hub/sdk/poll_edge_service.rb +132 -0
- data/lib/feature_hub/sdk/strategy_attributes.rb +227 -0
- data/lib/feature_hub/sdk/streaming_edge_service.rb +57 -0
- data/lib/feature_hub/sdk/version.rb +17 -0
- data/lib/featurehub-sdk.rb +30 -0
- data/sig/feature_hub/featurehub.rbs +256 -0
- metadata +146 -0
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module FeatureHub
|
6
|
+
module Sdk
|
7
|
+
module ContextKeys
|
8
|
+
USERKEY = :userkey
|
9
|
+
SESSION = :session
|
10
|
+
COUNTRY = :country
|
11
|
+
PLATFORM = :platform
|
12
|
+
DEVICE = :device
|
13
|
+
VERSION = :version
|
14
|
+
end
|
15
|
+
|
16
|
+
# the context holding user data
|
17
|
+
class ClientContext
|
18
|
+
attr_reader :repo
|
19
|
+
|
20
|
+
def initialize(repo)
|
21
|
+
@repo = repo
|
22
|
+
@attributes = {}
|
23
|
+
end
|
24
|
+
|
25
|
+
def user_key(value)
|
26
|
+
@attributes[ContextKeys::USERKEY] = [value]
|
27
|
+
self
|
28
|
+
end
|
29
|
+
|
30
|
+
def session_key(value)
|
31
|
+
@attributes[ContextKeys::SESSION] = [value]
|
32
|
+
self
|
33
|
+
end
|
34
|
+
|
35
|
+
def country(value)
|
36
|
+
@attributes[ContextKeys::COUNTRY] = [value]
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
def device(value)
|
41
|
+
@attributes[ContextKeys::DEVICE] = [value]
|
42
|
+
self
|
43
|
+
end
|
44
|
+
|
45
|
+
def platform(value)
|
46
|
+
@attributes[ContextKeys::PLATFORM] = [value]
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
def version(value)
|
51
|
+
@attributes[ContextKeys::VERSION] = [value]
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
# this takes an array parameter
|
56
|
+
def attribute_value(key, values)
|
57
|
+
if values.empty?
|
58
|
+
@attributes.delete(key.to_sym)
|
59
|
+
else
|
60
|
+
@attributes[key.to_sym] = if values.is_a?(Array)
|
61
|
+
values
|
62
|
+
else
|
63
|
+
[values]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
self
|
68
|
+
end
|
69
|
+
|
70
|
+
def clear
|
71
|
+
@attributes = {}
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
def get_attr(key, default_val = nil)
|
76
|
+
(@attributes[key.to_sym] || [default_val])[0]
|
77
|
+
end
|
78
|
+
|
79
|
+
def default_percentage_key
|
80
|
+
key = @attributes[ContextKeys::SESSION] || @attributes[ContextKeys::USERKEY]
|
81
|
+
if key.nil? || key.empty?
|
82
|
+
nil
|
83
|
+
else
|
84
|
+
key[0]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def enabled?(key)
|
89
|
+
feature(key).enabled?
|
90
|
+
end
|
91
|
+
|
92
|
+
def feature(key)
|
93
|
+
@repo.feature(key)
|
94
|
+
end
|
95
|
+
|
96
|
+
def set?(key)
|
97
|
+
feature(key).set?
|
98
|
+
end
|
99
|
+
|
100
|
+
def number(key)
|
101
|
+
feature(key).number
|
102
|
+
end
|
103
|
+
|
104
|
+
def string(key)
|
105
|
+
feature(key).string
|
106
|
+
end
|
107
|
+
|
108
|
+
def json(key)
|
109
|
+
data = feature(key).raw_json
|
110
|
+
return JSON.parse(data) if data
|
111
|
+
|
112
|
+
nil
|
113
|
+
end
|
114
|
+
|
115
|
+
def raw_json(key)
|
116
|
+
feature(key).raw_json
|
117
|
+
end
|
118
|
+
|
119
|
+
def flag(key)
|
120
|
+
feature(key).flag
|
121
|
+
end
|
122
|
+
|
123
|
+
def boolean(key)
|
124
|
+
feature(key).boolean
|
125
|
+
end
|
126
|
+
|
127
|
+
def exists?(key)
|
128
|
+
feature(key).exists?
|
129
|
+
end
|
130
|
+
|
131
|
+
def build
|
132
|
+
self
|
133
|
+
end
|
134
|
+
|
135
|
+
def build_sync
|
136
|
+
self
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# represents the strategies being evaluated locally
|
141
|
+
class ClientEvalFeatureContext < ClientContext
|
142
|
+
def initialize(repo, edge)
|
143
|
+
super(repo)
|
144
|
+
|
145
|
+
@edge = edge
|
146
|
+
end
|
147
|
+
|
148
|
+
def build
|
149
|
+
@edge.poll
|
150
|
+
self
|
151
|
+
end
|
152
|
+
|
153
|
+
def build_sync
|
154
|
+
self
|
155
|
+
end
|
156
|
+
|
157
|
+
def feature(key)
|
158
|
+
@repo.feature(key).with_context(self)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# context used when evaluating server side
|
163
|
+
class ServerEvalFeatureContext < ClientContext
|
164
|
+
def initialize(repo, edge)
|
165
|
+
super(repo)
|
166
|
+
|
167
|
+
@edge = edge
|
168
|
+
@old_header = nil
|
169
|
+
end
|
170
|
+
|
171
|
+
def build
|
172
|
+
new_header = @attributes.map { |k, v| "#{k}=#{URI.encode_www_form_component(v[0].to_s)}" } * "&"
|
173
|
+
|
174
|
+
if @old_header.nil? && new_header.empty?
|
175
|
+
@edge.poll
|
176
|
+
elsif new_header != @old_header
|
177
|
+
@old_header = new_header
|
178
|
+
@repo.not_ready!
|
179
|
+
|
180
|
+
@edge.context_change(new_header)
|
181
|
+
end
|
182
|
+
|
183
|
+
self
|
184
|
+
end
|
185
|
+
|
186
|
+
def build_sync
|
187
|
+
build
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FeatureHub
|
4
|
+
module Sdk
|
5
|
+
# interface style definition for all edge services
|
6
|
+
class EdgeService
|
7
|
+
# abstract
|
8
|
+
def initialize(repository, api_keys, edge_url, logger = nil) end
|
9
|
+
|
10
|
+
# abstract
|
11
|
+
def poll; end
|
12
|
+
|
13
|
+
# abstract
|
14
|
+
def context_change(new_header) end
|
15
|
+
|
16
|
+
# abstract
|
17
|
+
def close; end
|
18
|
+
end
|
19
|
+
|
20
|
+
# central dispatch class for FeatureHub SDK
|
21
|
+
class FeatureHubConfig
|
22
|
+
attr_reader :edge_url, :api_keys, :client_evaluated, :logger
|
23
|
+
|
24
|
+
def initialize(edge_url, api_keys, repository = nil, edge_provider = nil, logger = nil)
|
25
|
+
raise "edge_url is not set to a valid string" if edge_url.nil? || edge_url.strip.empty?
|
26
|
+
|
27
|
+
raise "api_keys must be an array of API keys" if api_keys.nil? || !api_keys.is_a?(Array) || api_keys.empty?
|
28
|
+
|
29
|
+
detect_client_evaluated(api_keys)
|
30
|
+
|
31
|
+
@edge_url = parse_edge_url(edge_url)
|
32
|
+
@api_keys = api_keys
|
33
|
+
@repository = repository || FeatureHub::Sdk::FeatureHubRepository.new
|
34
|
+
@edge_service_provider = edge_provider || method(:create_default_provider)
|
35
|
+
@logger = logger || FeatureHub::Sdk.default_logger
|
36
|
+
end
|
37
|
+
|
38
|
+
def repository(repo = nil)
|
39
|
+
@repository = repo || @repository
|
40
|
+
end
|
41
|
+
|
42
|
+
def init
|
43
|
+
get_or_create_edge_service.poll
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
def force_new_edge_service
|
48
|
+
if @edge_service
|
49
|
+
@edge_service&.close
|
50
|
+
@edge_service = nil
|
51
|
+
end
|
52
|
+
|
53
|
+
get_or_create_edge_service
|
54
|
+
end
|
55
|
+
|
56
|
+
# rubocop:disable Naming/AccessorMethodName
|
57
|
+
def get_or_create_edge_service
|
58
|
+
@edge_service = create_edge_service if @edge_service.nil?
|
59
|
+
|
60
|
+
@edge_service
|
61
|
+
end
|
62
|
+
# rubocop:enable Naming/AccessorMethodName
|
63
|
+
|
64
|
+
def edge_service_provider(edge_provider = nil)
|
65
|
+
return @edge_service_provider if edge_provider.nil?
|
66
|
+
|
67
|
+
@edge_service_provider = edge_provider
|
68
|
+
|
69
|
+
if @edge_service
|
70
|
+
@edge_service&.close
|
71
|
+
@edge_service = nil
|
72
|
+
end
|
73
|
+
|
74
|
+
edge_provider
|
75
|
+
end
|
76
|
+
|
77
|
+
def use_polling_edge_service(interval = ENV.fetch("FEATUREHUB_POLL_INTERVAL", "30").to_i); end
|
78
|
+
|
79
|
+
def new_context
|
80
|
+
get_or_create_edge_service
|
81
|
+
|
82
|
+
if @client_evaluated
|
83
|
+
ClientEvalFeatureContext.new(@repository, @edge_service)
|
84
|
+
else
|
85
|
+
ServerEvalFeatureContext.new(@repository, @edge_service)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def close
|
90
|
+
return if @edge_service.nil?
|
91
|
+
|
92
|
+
@edge_service.close
|
93
|
+
@edge_service = nil
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def create_edge_service
|
99
|
+
@edge_service_provider.call(@repository, @api_keys, @edge_url, @logger)
|
100
|
+
end
|
101
|
+
|
102
|
+
def create_default_provider(repo, api_keys, edge_url, logger)
|
103
|
+
# FeatureHub::Sdk::PollingEdgeService.new(repo, api_keys, edge_url, 10, logger)
|
104
|
+
FeatureHub::Sdk::StreamingEdgeService.new(repo, api_keys, edge_url, logger)
|
105
|
+
end
|
106
|
+
|
107
|
+
# rubocop:disable Style/GuardClause
|
108
|
+
def detect_client_evaluated(api_keys)
|
109
|
+
@client_evaluated = !api_keys.detect { |k| k.include?("*") }.nil?
|
110
|
+
if api_keys.detect { |k| (@client_evaluated && !k.include?("*")) || (!@client_evaluated && k.include?("*")) }
|
111
|
+
raise "api keys must all be of one type"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
# rubocop:enable Style/GuardClause
|
115
|
+
|
116
|
+
def parse_edge_url(edge_url)
|
117
|
+
if edge_url[-1] == "/"
|
118
|
+
edge_url
|
119
|
+
else
|
120
|
+
"#{edge_url}/"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FeatureHub
|
4
|
+
module Sdk
|
5
|
+
# the core implementation of a feature repository
|
6
|
+
class FeatureHubRepository < InternalFeatureRepository
|
7
|
+
attr_reader :features
|
8
|
+
|
9
|
+
def initialize(apply_features = nil)
|
10
|
+
super()
|
11
|
+
@strategy_matcher = apply_features || FeatureHub::Sdk::Impl::ApplyFeature.new
|
12
|
+
@interceptors = []
|
13
|
+
@features = {}
|
14
|
+
@ready = false
|
15
|
+
end
|
16
|
+
|
17
|
+
def apply(strategies, key, feature_id, context)
|
18
|
+
@strategy_matcher.apply(strategies, key, feature_id, context)
|
19
|
+
end
|
20
|
+
|
21
|
+
def notify(status, data)
|
22
|
+
return unless status
|
23
|
+
|
24
|
+
if status.to_sym == :failed
|
25
|
+
@ready = false
|
26
|
+
return
|
27
|
+
end
|
28
|
+
|
29
|
+
return if data.nil?
|
30
|
+
|
31
|
+
case status.to_sym
|
32
|
+
when :features
|
33
|
+
update_features(data)
|
34
|
+
@ready = true
|
35
|
+
when :feature
|
36
|
+
update_feature(data)
|
37
|
+
@ready = true
|
38
|
+
when :delete_feature
|
39
|
+
delete_feature(data)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def feature(key)
|
44
|
+
sym_key = key.to_sym
|
45
|
+
@features[sym_key] || make_feature_holder(sym_key)
|
46
|
+
end
|
47
|
+
|
48
|
+
def register_interceptor(interceptor)
|
49
|
+
@interceptors.push(interceptor)
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_interceptor(feature_value)
|
53
|
+
@interceptors.find { |interceptor| interceptor.intercepted_value(feature_value) }
|
54
|
+
end
|
55
|
+
|
56
|
+
def ready?
|
57
|
+
@ready
|
58
|
+
end
|
59
|
+
|
60
|
+
def not_ready!
|
61
|
+
@ready = false
|
62
|
+
end
|
63
|
+
|
64
|
+
def extract_feature_state
|
65
|
+
@features.values
|
66
|
+
.filter(&:exists?)
|
67
|
+
.map(&:feature_state)
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def delete_feature(data)
|
73
|
+
return unless data && data["key"]
|
74
|
+
|
75
|
+
feat = @features[data["key"].to_sym]
|
76
|
+
|
77
|
+
feat&.update_feature_state(nil)
|
78
|
+
end
|
79
|
+
|
80
|
+
def make_feature_holder(key)
|
81
|
+
fs = FeatureHub::Sdk::FeatureState.new(key, self)
|
82
|
+
@features[key.to_sym] = fs
|
83
|
+
fs
|
84
|
+
end
|
85
|
+
|
86
|
+
def update_features(data)
|
87
|
+
data.each do |feature|
|
88
|
+
update_feature(feature)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def update_feature(feature_state)
|
93
|
+
return if feature_state.nil? || feature_state["key"].nil?
|
94
|
+
|
95
|
+
key = feature_state["key"].to_sym
|
96
|
+
holder = @features[key]
|
97
|
+
if !holder
|
98
|
+
@features[key] = FeatureHub::Sdk::FeatureState.new(key, self, feature_state)
|
99
|
+
return
|
100
|
+
elsif feature_state["version"] < holder.version
|
101
|
+
return
|
102
|
+
elsif feature_state["version"] == holder.version && feature_state["value"] == holder.value # rubocop:disable Lint/DuplicateBranch
|
103
|
+
return
|
104
|
+
end
|
105
|
+
|
106
|
+
holder.update_feature_state(feature_state)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FeatureHub
|
4
|
+
module Sdk
|
5
|
+
# represents internal state of a feature
|
6
|
+
class FeatureState
|
7
|
+
attr_reader :key, :internal_feature_state, :encoded_strategies
|
8
|
+
|
9
|
+
def initialize(key, repo, feature_state = nil, parent_state = nil, ctx = nil)
|
10
|
+
@key = key.to_sym
|
11
|
+
@parent_state = parent_state
|
12
|
+
@ctx = ctx
|
13
|
+
@repo = repo
|
14
|
+
@encoded_strategies = []
|
15
|
+
|
16
|
+
if feature_state
|
17
|
+
_set_feature_state(feature_state)
|
18
|
+
else
|
19
|
+
@internal_feature_state = {}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def locked?
|
24
|
+
fs = feature_state
|
25
|
+
exists?(fs) ? fs["l"] : false
|
26
|
+
end
|
27
|
+
|
28
|
+
def exists?(top_feature = nil)
|
29
|
+
fs = top_feature || feature_state
|
30
|
+
!(fs.empty? || fs["l"].nil?)
|
31
|
+
end
|
32
|
+
|
33
|
+
def id
|
34
|
+
exists? ? @internal_feature_state["id"] : nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def feature_type
|
38
|
+
fs = feature_state
|
39
|
+
exists?(fs) ? fs["type"] : nil
|
40
|
+
end
|
41
|
+
|
42
|
+
def with_context(ctx)
|
43
|
+
FeatureState.new(@key, @repo, nil, self, ctx)
|
44
|
+
end
|
45
|
+
|
46
|
+
def update_feature_state(feature_state)
|
47
|
+
_set_feature_state(feature_state)
|
48
|
+
end
|
49
|
+
|
50
|
+
def feature_state
|
51
|
+
top_feature_state.internal_feature_state
|
52
|
+
end
|
53
|
+
|
54
|
+
def value
|
55
|
+
get_value(feature_type)
|
56
|
+
end
|
57
|
+
|
58
|
+
def version
|
59
|
+
fs = feature_state
|
60
|
+
exists?(fs) ? fs["version"] : -1
|
61
|
+
end
|
62
|
+
|
63
|
+
def string
|
64
|
+
get_value("STRING")
|
65
|
+
end
|
66
|
+
|
67
|
+
def number
|
68
|
+
get_value("NUMBER")
|
69
|
+
end
|
70
|
+
|
71
|
+
def raw_json
|
72
|
+
get_value("JSON")
|
73
|
+
end
|
74
|
+
|
75
|
+
def boolean
|
76
|
+
get_value("BOOLEAN")
|
77
|
+
end
|
78
|
+
|
79
|
+
def flag
|
80
|
+
boolean
|
81
|
+
end
|
82
|
+
|
83
|
+
def enabled?
|
84
|
+
boolean == true
|
85
|
+
end
|
86
|
+
|
87
|
+
def set?
|
88
|
+
value != nil?
|
89
|
+
end
|
90
|
+
|
91
|
+
def top_feature_state
|
92
|
+
return @parent_state&.top_feature_state if @parent_state
|
93
|
+
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def _feature_state
|
100
|
+
@internal_feature_state
|
101
|
+
end
|
102
|
+
|
103
|
+
def _set_feature_state(feature_state)
|
104
|
+
@internal_feature_state = feature_state || {}
|
105
|
+
found_strategies = feature_state["strategies"] || []
|
106
|
+
@encoded_strategies = found_strategies.map { |s| FeatureHub::Sdk::Impl::RolloutStrategy.new(s) }
|
107
|
+
end
|
108
|
+
|
109
|
+
def get_value(feature_type)
|
110
|
+
unless locked?
|
111
|
+
intercept = @repo.find_interceptor(@key)
|
112
|
+
|
113
|
+
return intercept.cast(feature_type) if intercept
|
114
|
+
end
|
115
|
+
|
116
|
+
fs = top_feature_state
|
117
|
+
|
118
|
+
state = fs.internal_feature_state
|
119
|
+
|
120
|
+
return nil if state.nil?
|
121
|
+
|
122
|
+
return nil if fs.nil? || (!feature_type.nil? && fs.feature_type != feature_type)
|
123
|
+
|
124
|
+
if @ctx
|
125
|
+
matched = @repo.apply(fs.encoded_strategies, @key, fs.id, @ctx)
|
126
|
+
|
127
|
+
return FeatureHub::Sdk::InterceptorValue.new(matched.value).cast(feature_type) if matched.matched
|
128
|
+
end
|
129
|
+
|
130
|
+
state["value"]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "time"
|
4
|
+
|
5
|
+
module FeatureHub
|
6
|
+
module Sdk
|
7
|
+
module Impl
|
8
|
+
# represents the application of a match, either successfully or not
|
9
|
+
class Applied
|
10
|
+
attr_reader :matched, :value
|
11
|
+
|
12
|
+
def initialize(matched, value)
|
13
|
+
@matched = matched
|
14
|
+
@value = value
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# full mechanism for applying client side evaluation
|
19
|
+
class ApplyFeature
|
20
|
+
def initialize(percent_calculator = nil, matcher_repository = nil)
|
21
|
+
@percentage_calculator = percent_calculator || FeatureHub::Sdk::Impl::Murmur3PercentageCalculator.new
|
22
|
+
@matcher_repository = matcher_repository || FeatureHub::Sdk::Impl::MatcherRegistry.new
|
23
|
+
end
|
24
|
+
|
25
|
+
def apply(strategies, _key, feature_value_id, context)
|
26
|
+
return Applied.new(false, nil) if context.nil? || strategies.nil? || strategies.empty?
|
27
|
+
|
28
|
+
percentage = nil
|
29
|
+
percentage_key = nil
|
30
|
+
base_percentage = {}
|
31
|
+
default_percentage_key = context.default_percentage_key
|
32
|
+
|
33
|
+
strategies.each do |rsi|
|
34
|
+
if (rsi.percentage != 0) && (!default_percentage_key.nil? || rsi.percentage_attributes?)
|
35
|
+
new_percentage_key = ApplyFeature.determine_percentage_key(context, rsi)
|
36
|
+
|
37
|
+
base_percentage[new_percentage_key] = 0 if base_percentage[new_percentage_key].nil?
|
38
|
+
|
39
|
+
base_percentage_val = base_percentage[new_percentage_key]
|
40
|
+
|
41
|
+
if percentage.nil? || new_percentage_key != percentage_key
|
42
|
+
percentage_key = new_percentage_key
|
43
|
+
percentage = @percentage_calculator.determine_client_percentage(percentage_key, feature_value_id)
|
44
|
+
use_base_percentage = rsi.attributes? ? 0 : base_percentage_val
|
45
|
+
|
46
|
+
# rubocop:disable Layout/MultilineOperationIndentation
|
47
|
+
if percentage <= (use_base_percentage + rsi.percentage) &&
|
48
|
+
(!rsi.attributes? || (rsi.attributes? && match_attribute(context, rsi)))
|
49
|
+
return Applied.new(true, rsi.value)
|
50
|
+
end
|
51
|
+
|
52
|
+
# rubocop:enable Layout/MultilineOperationIndentation
|
53
|
+
|
54
|
+
unless rsi.attributes?
|
55
|
+
base_percentage[percentage_key] =
|
56
|
+
base_percentage[percentage_key] + rsi.percentage
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
return Applied.new(true, rsi.value) if rsi.percentage.zero? && rsi.attributes? && match_attribute(context,
|
62
|
+
rsi)
|
63
|
+
end
|
64
|
+
|
65
|
+
Applied.new(false, nil)
|
66
|
+
end
|
67
|
+
|
68
|
+
def match_attribute(context, rs_attr)
|
69
|
+
rs_attr.attributes.each do |attr|
|
70
|
+
supplied_value = context.get_attr(attr.field_name)
|
71
|
+
if supplied_value.nil? && attr.field_name.downcase == "now"
|
72
|
+
case attr.field_type
|
73
|
+
when "DATE"
|
74
|
+
supplied_value = Time.new.utc.iso8601[0..9]
|
75
|
+
when "DATETIME"
|
76
|
+
supplied_value = Time.new.utc.iso8601
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
if attr.values.nil? && supplied_value.nil?
|
81
|
+
return false unless attr.conditional.equals?
|
82
|
+
|
83
|
+
next
|
84
|
+
end
|
85
|
+
|
86
|
+
return false if attr.values.nil? || supplied_value.nil?
|
87
|
+
|
88
|
+
# this attribute has to match or we failed
|
89
|
+
return false unless @matcher_repository.find_matcher(attr).match(supplied_value, attr)
|
90
|
+
end
|
91
|
+
|
92
|
+
true
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.determine_percentage_key(context, rsi)
|
96
|
+
if rsi.percentage_attributes?
|
97
|
+
rsi.percentage_attributes.map { |attr| context.get_attr(attr, "<none>") }.join("$")
|
98
|
+
else
|
99
|
+
context.default_percentage_key
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "murmurhash3"
|
4
|
+
|
5
|
+
module FeatureHub
|
6
|
+
module Sdk
|
7
|
+
module Impl
|
8
|
+
# consistent across all platforms murmur percentage calculator
|
9
|
+
class Murmur3PercentageCalculator < FeatureHub::Sdk::PercentageCalculator
|
10
|
+
MAX_PERCENTAGE = 1_000_000
|
11
|
+
SEED = 0
|
12
|
+
|
13
|
+
def determine_client_percentage(percentage_text, feature_id)
|
14
|
+
result = MurmurHash3::V32.str_digest(percentage_text + feature_id, SEED).unpack1("L").to_f
|
15
|
+
(result / (2**32) * MAX_PERCENTAGE).floor
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|