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