featurehub-sdk 1.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.
- 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
|