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.
@@ -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