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,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