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