featurehub-sdk 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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