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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # these are bulk copied in from OpenAPI and left in original case
4
+ module FeatureHub
5
+ module Sdk
6
+ module Impl
7
+ # represents a condition for this attribute
8
+ class RolloutStrategyAttributeCondition
9
+ def initialize(attr)
10
+ @attr = attr
11
+ end
12
+
13
+ def equals?
14
+ @attr == "EQUALS"
15
+ end
16
+
17
+ def ends_with?
18
+ @attr == "ENDS_WITH"
19
+ end
20
+
21
+ def starts_with?
22
+ @attr == "STARTS_WITH"
23
+ end
24
+
25
+ def greater?
26
+ @attr == "GREATER"
27
+ end
28
+
29
+ def greater_equals?
30
+ @attr == "GREATER_EQUALS"
31
+ end
32
+
33
+ def less?
34
+ @attr == "LESS"
35
+ end
36
+
37
+ def less_equals?
38
+ @attr == "LESS_EQUALS"
39
+ end
40
+
41
+ def not_equals?
42
+ @attr == "NOT_EQUALS"
43
+ end
44
+
45
+ def includes?
46
+ @attr == "INCLUDES"
47
+ end
48
+
49
+ def excludes?
50
+ @attr == "EXCLUDES"
51
+ end
52
+
53
+ def regex?
54
+ @attr == "REGEX"
55
+ end
56
+
57
+ def to_s
58
+ @attr
59
+ end
60
+ end
61
+
62
+ # represents an individual attribute comparison
63
+ class RolloutStrategyAttribute
64
+ attr_reader :id, :conditional, :field_name, :values, :field_type
65
+
66
+ def initialize(attr)
67
+ @attr = attr
68
+ @id = @attr["id"]
69
+ @conditional = RolloutStrategyAttributeCondition.new(@attr["conditional"])
70
+ @field_name = @attr["fieldName"]
71
+ @values = @attr["values"]
72
+ @field_type = @attr["type"]
73
+ end
74
+
75
+ def float_values
76
+ @values.filter { |x| !x.nil? }.map(&:to_f)
77
+ end
78
+
79
+ def str_values
80
+ @values.filter { |x| !x.nil? }.map(&:to_s)
81
+ end
82
+
83
+ def to_s
84
+ "id: #{@id}, conditional: #{@conditional}, field_name: #{@field_name}, " \
85
+ "values: #{@values}, field_type: #{field_type}"
86
+ end
87
+ end
88
+
89
+ # represents a raw rollout strategy inside a feature
90
+ class RolloutStrategy
91
+ attr_reader :attributes, :id, :name, :percentage, :percentage_attributes, :value
92
+
93
+ def initialize(strategy)
94
+ @strategy = strategy
95
+ @attributes = (strategy["attributes"] || []).map { |attr| RolloutStrategyAttribute.new(attr) }
96
+ @id = strategy["id"]
97
+ @name = strategy["name"]
98
+ @percentage = (strategy["percentage"] || "0").to_i
99
+ @percentage_attributes = (strategy["percentageAttributes"] || [])
100
+ @value = strategy["value"]
101
+ end
102
+
103
+ def percentage_attributes?
104
+ !@percentage_attributes.empty?
105
+ end
106
+
107
+ def attributes?
108
+ !@attributes.empty?
109
+ end
110
+
111
+ def to_s
112
+ "id: #{@id}, name: #{@name}, percentage: #{@percentage}, percentage_attrs: #{@percentage_attributes}, " \
113
+ "value: #{@value}, attributes: [#{@attributes.map(&:to_s).join(",")}]"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sem_version"
4
+
5
+ module FeatureHub
6
+ module Sdk
7
+ module Impl
8
+ # generic strategy matcher (and fallthrough)
9
+ class StrategyMatcher
10
+ def match(_supplied_value, _attr)
11
+ false
12
+ end
13
+ end
14
+
15
+ # comparison for true/false
16
+ class BooleanMatcher < StrategyMatcher
17
+ def match(supplied_value, attr)
18
+ val = supplied_value.downcase == "true"
19
+
20
+ if attr.conditional.equals?
21
+ val == (attr.values[0].to_s.downcase == "true")
22
+ elsif attr.conditional.not_equals?
23
+ val != (attr.values[0].to_s.downcase == "true")
24
+ else
25
+ false
26
+ end
27
+ end
28
+ end
29
+
30
+ # matches for strings, dates and date-times
31
+ class StringMatcher < StrategyMatcher
32
+ def match(supplied_value, attr)
33
+ vals = attr.str_values
34
+
35
+ cond = attr.conditional
36
+ if cond.equals?
37
+ vals.any? { |v| supplied_value == v }
38
+ elsif cond.not_equals?
39
+ vals.none? { |v| supplied_value == v }
40
+ elsif cond.ends_with?
41
+ vals.any? { |v| supplied_value.end_with?(v) }
42
+ elsif cond.starts_with?
43
+ vals.any? { |v| supplied_value.start_with?(v) }
44
+ elsif cond.greater?
45
+ vals.any? { |v| supplied_value > v }
46
+ elsif cond.greater_equals?
47
+ vals.any? { |v| supplied_value >= v }
48
+ elsif cond.less?
49
+ vals.any? { |v| supplied_value < v }
50
+ elsif cond.less_equals?
51
+ vals.any? { |v| supplied_value <= v }
52
+ elsif cond.includes?
53
+ vals.any? { |v| supplied_value.include?(v) }
54
+ elsif cond.excludes?
55
+ vals.none? { |v| supplied_value.include?(v) }
56
+ elsif cond.regex?
57
+ vals.any? { |v| !Regexp.new(v).match(supplied_value).nil? }
58
+ else
59
+ false
60
+ end
61
+ end
62
+ end
63
+
64
+ # matches floating point numbers excep for end/start/regexp
65
+ class NumberMatcher < StrategyMatcher
66
+ def match(supplied_value, attr)
67
+ cond = attr.conditional
68
+
69
+ if cond.ends_with?
70
+ attr.str_values.any? { |v| supplied_value.end_with?(v) }
71
+ elsif cond.starts_with?
72
+ attr.str_values.any? { |v| supplied_value.start_with?(v) }
73
+ elsif cond.excludes?
74
+ attr.str_values.none? { |v| supplied_value.include?(v) }
75
+ elsif cond.includes?
76
+ attr.str_values.any? { |v| supplied_value.include?(v) }
77
+ elsif cond.regex?
78
+ attr.str_values.any? { |v| !Regexp.new(v).match(supplied_value).nil? }
79
+ else
80
+ parsed_val = supplied_value.to_f
81
+ vals = attr.float_values
82
+
83
+ if cond.equals?
84
+ vals.any? { |v| parsed_val == v }
85
+ elsif cond.not_equals?
86
+ vals.none? { |v| parsed_val == v }
87
+ elsif cond.greater?
88
+ vals.any? { |v| parsed_val > v }
89
+ elsif cond.greater_equals?
90
+ vals.any? { |v| parsed_val >= v }
91
+ elsif cond.less?
92
+ vals.any? { |v| parsed_val < v }
93
+ elsif cond.less_equals?
94
+ vals.any? { |v| parsed_val <= v }
95
+ elsif cond.includes?
96
+ vals.any? { |v| parsed_val.include?(v) }
97
+ elsif cond.excludes?
98
+ vals.none? { |v| parsed_val.include?(v) }
99
+ else
100
+ false
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # NumberMatcher
107
+
108
+ # matches using semantic versions
109
+ class SemanticVersionMatcher < StrategyMatcher
110
+ def match(supplied_value, attr)
111
+ cond = attr.conditional
112
+
113
+ val = SemVersion.new(supplied_value)
114
+ vals = attr.str_values
115
+
116
+ if cond.includes? || cond.equals?
117
+ vals.any? { |v| val.satisfies?(v) }
118
+ elsif cond.excludes? || cond.not_equals?
119
+ vals.none? { |v| val.satisfies?(v) }
120
+ else
121
+ comparison_vals = vals.filter { |x| SemVersion.valid?(x) }
122
+ if cond.greater?
123
+ comparison_vals.any? { |v| supplied_value > v }
124
+ elsif cond.greater_equals?
125
+ comparison_vals.any? { |v| supplied_value >= v }
126
+ elsif cond.less?
127
+ comparison_vals.any? { |v| supplied_value < v }
128
+ elsif cond.less_equals?
129
+ comparison_vals.any? { |v| supplied_value <= v }
130
+ else
131
+ false
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # matches based on ip addresses and CIDRs
138
+ class IpNetworkMatcher < StrategyMatcher
139
+ def match(supplied_value, attr)
140
+ cond = attr.conditional
141
+
142
+ val = IPAddr.new(supplied_value)
143
+ vals = attr.str_values
144
+
145
+ if cond.includes? || cond.equals?
146
+ vals.any? { |v| IPAddr.new(v).include?(val) }
147
+ elsif cond.excludes? || cond.not_equals?
148
+ vals.none? { |v| IPAddr.new(v).include?(val) }
149
+ else
150
+ false
151
+ end
152
+ end
153
+ end
154
+
155
+ # interface for the matcher repository finder
156
+ class MatcherRepository
157
+ def find_matcher(attr); end
158
+ end
159
+
160
+ # figures out what attribute type this is and passes back the right matcher
161
+ class MatcherRegistry < MatcherRepository
162
+ def find_matcher(attr)
163
+ case attr.field_type
164
+ when "BOOLEAN"
165
+ BooleanMatcher.new
166
+ when "STRING", "DATE", "DATE_TIME"
167
+ StringMatcher.new
168
+ when "SEMANTIC_VERSION"
169
+ SemanticVersionMatcher.new
170
+ when "NUMBER"
171
+ NumberMatcher.new
172
+ when "IP_ADDRESS"
173
+ IpNetworkMatcher.new
174
+ else
175
+ StrategyMatcher.new
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatureHub
4
+ module Sdk
5
+ # represents an intercepted value
6
+ class InterceptorValue
7
+ def initialize(val)
8
+ @val = val
9
+ end
10
+
11
+ def cast(expected_type)
12
+ return @val if expected_type.nil? || @val.nil?
13
+
14
+ case expected_type
15
+ when "BOOLEAN"
16
+ @val.to_s.downcase.strip == "true"
17
+ when "NUMBER"
18
+ @val.to_s.to_f
19
+ else
20
+ @val.to_s
21
+ end
22
+ end
23
+ end
24
+
25
+ # Holds the pattern for a value based interceptor, which could come from a file, or whatever
26
+ # they are not typed
27
+ class ValueInterceptor
28
+ def intercepted_value(feature_key); end
29
+ end
30
+
31
+ # An example of a value interceptor that uses environment variables
32
+ class EnvironmentInterceptor < ValueInterceptor
33
+ def initialize
34
+ super()
35
+ @enabled = ENV.fetch("FEATUREHUB_OVERRIDE_FEATURES", "false") == "true"
36
+ end
37
+
38
+ def intercepted_value(feature_key)
39
+ if @enabled
40
+ found = ENV.fetch("FEATUREHUB_#{sanitize_feature_name(feature_key.to_s)}", nil)
41
+ return InterceptorValue.new(found) unless found.nil?
42
+ end
43
+
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def sanitize_feature_name(key)
50
+ key.tr(" ", "_")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatureHub
4
+ module Sdk
5
+ # surface features of a repository that must be implemented for any repository wrapper
6
+ class InternalFeatureRepository
7
+ def feature(_key)
8
+ nil
9
+ end
10
+
11
+ def find_interceptor(_feature_value)
12
+ nil
13
+ end
14
+
15
+ def ready?
16
+ false
17
+ end
18
+
19
+ def not_ready!; end
20
+
21
+ def apply(_strategies, _key, _feature_id, _context)
22
+ Applied.new(false, nil)
23
+ end
24
+
25
+ def notify(status, data); end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatureHub
4
+ module Sdk
5
+ # generic percentage calculator
6
+ class PercentageCalculator
7
+ def determine_client_percentage(_percentage_text, _feature_id)
8
+ 0
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/net_http"
5
+ require "json"
6
+ require "concurrent-ruby"
7
+
8
+ module FeatureHub
9
+ module Sdk
10
+ # uses a periodic polling mechanism to get updates
11
+ class PollingEdgeService < EdgeService
12
+ attr_reader :repository, :api_keys, :edge_url, :interval
13
+
14
+ def initialize(repository, api_keys, edge_url, interval, logger = nil)
15
+ super(repository, api_keys, edge_url)
16
+
17
+ @repository = repository
18
+ @api_keys = api_keys
19
+ @edge_url = edge_url
20
+ @interval = interval
21
+
22
+ @logger = logger || FeatureHub::Sdk.default_logger
23
+
24
+ @task = nil
25
+ @cancel = false
26
+ @context = nil
27
+ @etag = nil
28
+
29
+ generate_url
30
+ end
31
+
32
+ # abstract
33
+ def poll
34
+ @cancel = false
35
+ poll_with_interval
36
+ end
37
+
38
+ def update_interval(interval)
39
+ @interval = interval
40
+ if @task.nil?
41
+ poll
42
+ else
43
+ @task.execution_interval = interval
44
+ end
45
+ end
46
+
47
+ def context_change(new_header)
48
+ return if new_header == @context
49
+
50
+ @context = new_header
51
+
52
+ get_updates
53
+ end
54
+
55
+ def close
56
+ cancel_task
57
+ end
58
+
59
+ private
60
+
61
+ def poll_with_interval
62
+ return if @cancel || !@task.nil?
63
+
64
+ get_updates
65
+
66
+ @logger.info("starting polling for #{determine_request_url}")
67
+ @task = Concurrent::TimerTask.new(execution_interval: @interval) do
68
+ get_updates
69
+ end
70
+ @task.execute
71
+ end
72
+
73
+ def cancel_task
74
+ return if @task.nil?
75
+
76
+ @task.shutdown
77
+ @task = nil
78
+ @cancel = true
79
+ end
80
+
81
+ # rubocop:disable Naming/AccessorMethodName
82
+ def get_updates
83
+ url = determine_request_url
84
+ headers = {
85
+ accept: "application/json",
86
+ "X-SDK": "Ruby",
87
+ "X-SDK-Version": FeatureHub::Sdk::VERSION
88
+ }
89
+ headers["if-none-match"] = @etag unless @etag.nil?
90
+ @logger.debug("polling for #{url}")
91
+ resp = @conn.get(url, request: { timeout: @timeout }, headers: headers)
92
+ case resp.status
93
+ when 200
94
+ @etag = resp.headers["etag"]
95
+ process_results(JSON.parse(resp.body))
96
+ when 404 # no such key
97
+ @repository.notify("failed", nil)
98
+ @cancel = true
99
+ @logger.error("featurehub: key does not exist, stopping polling")
100
+ when 503 # dacha busy
101
+ @logger.debug("featurehub: dacha is busy, trying tgaina")
102
+ else
103
+ @logger.debug("featurehub: unknown error #{resp.status}")
104
+ end
105
+ end
106
+ # rubocop:enable Naming/AccessorMethodName
107
+
108
+ def process_results(data)
109
+ data.each do |environment|
110
+ @repository.notify("features", environment["features"]) if environment
111
+ end
112
+ end
113
+
114
+ def determine_request_url
115
+ if @context.nil?
116
+ @url
117
+ else
118
+ "#{@url}&#{@context}"
119
+ end
120
+ end
121
+
122
+ def generate_url
123
+ api_key_cat = (@api_keys.map { |key| "apiKey=#{key}" } * "&")
124
+ @url = "features?#{api_key_cat}"
125
+ @timeout = ENV.fetch("FEATUREHUB_POLL_HTTP_TIMEOUT", "12").to_i
126
+ @conn = Faraday.new(url: @edge_url) do |f|
127
+ f.adapter :net_http
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end