toggly 0.1.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/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +34 -0
- data/lib/toggly/client.rb +287 -0
- data/lib/toggly/config.rb +139 -0
- data/lib/toggly/context.rb +149 -0
- data/lib/toggly/definitions_provider.rb +288 -0
- data/lib/toggly/errors.rb +56 -0
- data/lib/toggly/evaluation_engine.rb +136 -0
- data/lib/toggly/evaluators/always_off.rb +22 -0
- data/lib/toggly/evaluators/always_on.rb +22 -0
- data/lib/toggly/evaluators/base.rb +55 -0
- data/lib/toggly/evaluators/contextual_targeting.rb +116 -0
- data/lib/toggly/evaluators/percentage.rb +72 -0
- data/lib/toggly/evaluators/targeting.rb +51 -0
- data/lib/toggly/evaluators/time_window.rb +53 -0
- data/lib/toggly/feature_definition.rb +188 -0
- data/lib/toggly/registry.rb +86 -0
- data/lib/toggly/snapshot_providers/base.rb +67 -0
- data/lib/toggly/snapshot_providers/file.rb +95 -0
- data/lib/toggly/snapshot_providers/memory.rb +59 -0
- data/lib/toggly/version.rb +5 -0
- data/lib/toggly.rb +94 -0
- metadata +73 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require "websocket-client-simple"
|
|
9
|
+
HAS_WEBSOCKET = true
|
|
10
|
+
rescue LoadError
|
|
11
|
+
HAS_WEBSOCKET = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Toggly
|
|
15
|
+
# Provider for fetching feature definitions from Toggly API.
|
|
16
|
+
class DefinitionsProvider
|
|
17
|
+
# Fallback HTTP refresh interval when WebSocket is connected (20 minutes)
|
|
18
|
+
FALLBACK_REFRESH_INTERVAL = 20 * 60
|
|
19
|
+
|
|
20
|
+
# Delay before attempting WebSocket reconnection (seconds)
|
|
21
|
+
WS_RECONNECT_DELAY = 5
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] Whether the WebSocket connection is active
|
|
24
|
+
attr_reader :ws_connected
|
|
25
|
+
|
|
26
|
+
# @param config [Config] Configuration
|
|
27
|
+
# @param logger [Logger, nil] Optional logger
|
|
28
|
+
# @param on_definitions_updated [Proc, nil] Callback when WS signals an update
|
|
29
|
+
def initialize(config:, logger: nil, on_definitions_updated: nil)
|
|
30
|
+
@config = config
|
|
31
|
+
@logger = logger
|
|
32
|
+
@on_definitions_updated = on_definitions_updated
|
|
33
|
+
@etag = nil
|
|
34
|
+
@last_modified = nil
|
|
35
|
+
|
|
36
|
+
# WebSocket state
|
|
37
|
+
@ws = nil
|
|
38
|
+
@ws_connected = false
|
|
39
|
+
@ws_thread = nil
|
|
40
|
+
@ws_closing = false
|
|
41
|
+
@last_fallback_refresh = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fetch definitions from the API
|
|
45
|
+
#
|
|
46
|
+
# @param force [Boolean] Force fetch even if cached
|
|
47
|
+
# @return [Hash, nil] Hash of definitions, or nil if not modified
|
|
48
|
+
# @raise [NetworkError] On network failures
|
|
49
|
+
# @raise [DefinitionsError] On API errors
|
|
50
|
+
def fetch(force: false)
|
|
51
|
+
return nil if @config.offline_mode?
|
|
52
|
+
|
|
53
|
+
uri = URI.parse(@config.definitions_endpoint)
|
|
54
|
+
http = build_http(uri)
|
|
55
|
+
request = build_request(uri, force)
|
|
56
|
+
|
|
57
|
+
response = http.request(request)
|
|
58
|
+
handle_response(response)
|
|
59
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
60
|
+
raise NetworkError, "Request timeout: #{e.message}"
|
|
61
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
62
|
+
raise NetworkError, "Connection failed: #{e.message}"
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
raise NetworkError, "Request failed: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Reset cached headers (force full fetch next time)
|
|
68
|
+
def reset_cache
|
|
69
|
+
@etag = nil
|
|
70
|
+
@last_modified = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check whether the periodic refresh should be skipped because the
|
|
74
|
+
# WebSocket connection is active and the fallback interval has not elapsed.
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean] true if the refresh should be skipped
|
|
77
|
+
def should_skip_refresh?
|
|
78
|
+
return false unless @ws_connected
|
|
79
|
+
return false if @last_fallback_refresh.nil?
|
|
80
|
+
|
|
81
|
+
if (Time.now - @last_fallback_refresh) < FALLBACK_REFRESH_INTERVAL
|
|
82
|
+
log_debug("WebSocket connected; skipping periodic HTTP refresh")
|
|
83
|
+
true
|
|
84
|
+
else
|
|
85
|
+
@last_fallback_refresh = Time.now
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Start a WebSocket connection for live definition updates.
|
|
91
|
+
# Requires the `websocket-client-simple` gem. If the gem is not installed
|
|
92
|
+
# this method logs a message and returns without error.
|
|
93
|
+
def start_websocket
|
|
94
|
+
unless HAS_WEBSOCKET
|
|
95
|
+
log_debug("websocket-client-simple gem not available; live updates disabled")
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return if @config.offline_mode?
|
|
100
|
+
|
|
101
|
+
@ws_closing = false
|
|
102
|
+
|
|
103
|
+
@ws_thread = Thread.new do
|
|
104
|
+
loop do
|
|
105
|
+
break if @ws_closing
|
|
106
|
+
|
|
107
|
+
connect_websocket
|
|
108
|
+
break if @ws_closing
|
|
109
|
+
|
|
110
|
+
log_debug("WebSocket disconnected, reconnecting in #{WS_RECONNECT_DELAY}s")
|
|
111
|
+
sleep(WS_RECONNECT_DELAY)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@ws_thread.abort_on_exception = false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Stop the WebSocket connection and its management thread.
|
|
119
|
+
def stop_websocket
|
|
120
|
+
@ws_closing = true
|
|
121
|
+
@ws_connected = false
|
|
122
|
+
|
|
123
|
+
if @ws
|
|
124
|
+
begin
|
|
125
|
+
@ws.close
|
|
126
|
+
rescue StandardError
|
|
127
|
+
# Ignore errors during close
|
|
128
|
+
end
|
|
129
|
+
@ws = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return unless @ws_thread
|
|
133
|
+
|
|
134
|
+
@ws_thread.kill
|
|
135
|
+
@ws_thread = nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def build_http(uri)
|
|
141
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
142
|
+
http.use_ssl = uri.scheme == "https"
|
|
143
|
+
http.open_timeout = @config.http_timeout
|
|
144
|
+
http.read_timeout = @config.http_timeout
|
|
145
|
+
http
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_request(uri, force)
|
|
149
|
+
request = Net::HTTP::Get.new(uri)
|
|
150
|
+
request["Accept"] = "application/json"
|
|
151
|
+
request["User-Agent"] = "toggly-ruby/#{Toggly::VERSION}"
|
|
152
|
+
|
|
153
|
+
# Add app version if configured
|
|
154
|
+
request["X-App-Version"] = @config.app_version if @config.app_version
|
|
155
|
+
|
|
156
|
+
# Add instance name if configured
|
|
157
|
+
request["X-Instance-Name"] = @config.instance_name if @config.instance_name
|
|
158
|
+
|
|
159
|
+
# Add conditional headers unless forcing
|
|
160
|
+
unless force
|
|
161
|
+
request["If-None-Match"] = @etag if @etag
|
|
162
|
+
request["If-Modified-Since"] = @last_modified if @last_modified
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
request
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def handle_response(response)
|
|
169
|
+
case response.code.to_i
|
|
170
|
+
when 200
|
|
171
|
+
parse_definitions(response)
|
|
172
|
+
when 304
|
|
173
|
+
# Not modified
|
|
174
|
+
log_debug("Definitions not modified")
|
|
175
|
+
nil
|
|
176
|
+
when 401, 403
|
|
177
|
+
raise DefinitionsError, "Authentication failed: #{response.code}"
|
|
178
|
+
when 404
|
|
179
|
+
raise DefinitionsError, "Definitions not found (check app_key and environment)"
|
|
180
|
+
else
|
|
181
|
+
raise NetworkError.new(
|
|
182
|
+
"API error: #{response.code}",
|
|
183
|
+
status_code: response.code.to_i,
|
|
184
|
+
response_body: response.body
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_definitions(response)
|
|
190
|
+
# Cache headers for conditional requests
|
|
191
|
+
@etag = response["ETag"]
|
|
192
|
+
@last_modified = response["Last-Modified"]
|
|
193
|
+
|
|
194
|
+
data = JSON.parse(response.body)
|
|
195
|
+
features = if data.is_a?(Hash)
|
|
196
|
+
data["defs"] || data["features"] || data
|
|
197
|
+
else
|
|
198
|
+
data
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Handle both array and object formats
|
|
202
|
+
if features.is_a?(Array)
|
|
203
|
+
features.each_with_object({}) do |item, hash|
|
|
204
|
+
definition = FeatureDefinition.from_hash(item)
|
|
205
|
+
hash[definition.feature_key] = definition
|
|
206
|
+
end
|
|
207
|
+
elsif features.is_a?(Hash)
|
|
208
|
+
features.transform_values { |v| FeatureDefinition.from_hash(v) }
|
|
209
|
+
else
|
|
210
|
+
raise DefinitionsError, "Invalid definitions format"
|
|
211
|
+
end
|
|
212
|
+
rescue JSON::ParserError => e
|
|
213
|
+
raise DefinitionsError, "Failed to parse definitions: #{e.message}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build_websocket_url
|
|
217
|
+
base = @config.definitions_url || @config.base_url
|
|
218
|
+
ws_url = base.gsub(%r{^https://}, "wss://").gsub(%r{^http://}, "ws://")
|
|
219
|
+
ws_url = ws_url.chomp("/")
|
|
220
|
+
"#{ws_url}/#{@config.app_key}/ws"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def connect_websocket
|
|
224
|
+
url = build_websocket_url
|
|
225
|
+
log_debug("Connecting WebSocket to: #{url}")
|
|
226
|
+
|
|
227
|
+
provider = self
|
|
228
|
+
connected_flag = false
|
|
229
|
+
|
|
230
|
+
begin
|
|
231
|
+
@ws = WebSocket::Client::Simple.connect(url)
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
log_error("Failed to create WebSocket: #{e.message}")
|
|
234
|
+
@ws = nil
|
|
235
|
+
return
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
@ws.on :open do
|
|
239
|
+
provider.instance_variable_set(:@ws_connected, true)
|
|
240
|
+
provider.instance_variable_set(:@last_fallback_refresh, Time.now)
|
|
241
|
+
connected_flag = true
|
|
242
|
+
provider.send(:log_debug, "WebSocket connected")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
@ws.on :message do |msg|
|
|
246
|
+
text = msg.data.to_s
|
|
247
|
+
data = JSON.parse(text)
|
|
248
|
+
msg_type = data["type"]
|
|
249
|
+
|
|
250
|
+
next if msg_type == "ping"
|
|
251
|
+
|
|
252
|
+
if %w[flags-updated update].include?(msg_type)
|
|
253
|
+
provider.send(:log_debug, "WebSocket: definitions updated, refreshing")
|
|
254
|
+
provider.instance_variable_get(:@on_definitions_updated)&.call
|
|
255
|
+
end
|
|
256
|
+
rescue JSON::ParserError
|
|
257
|
+
# Non-JSON message - check for plain text signals
|
|
258
|
+
plain = msg.data.to_s.strip
|
|
259
|
+
if %w[update flags-updated].include?(plain)
|
|
260
|
+
provider.send(:log_debug, "WebSocket: definitions updated (plain text), refreshing")
|
|
261
|
+
provider.instance_variable_get(:@on_definitions_updated)&.call
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
@ws.on :error do |e|
|
|
266
|
+
provider.send(:log_error, "WebSocket error: #{e.message}")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
@ws.on :close do |_e|
|
|
270
|
+
provider.instance_variable_set(:@ws_connected, false)
|
|
271
|
+
provider.instance_variable_set(:@ws, nil)
|
|
272
|
+
connected_flag = false
|
|
273
|
+
provider.send(:log_debug, "WebSocket connection closed")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Block until disconnected or closing
|
|
277
|
+
sleep(0.1) until !@ws || @ws_closing || (@ws && !@ws.open?)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def log_debug(message)
|
|
281
|
+
@logger&.debug("[Toggly] #{message}")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def log_error(message)
|
|
285
|
+
@logger&.error("[Toggly] #{message}")
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Base error class for all Toggly errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when configuration is invalid
|
|
8
|
+
class ConfigError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when network requests fail
|
|
11
|
+
class NetworkError < Error
|
|
12
|
+
attr_reader :status_code, :response_body
|
|
13
|
+
|
|
14
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@status_code = status_code
|
|
17
|
+
@response_body = response_body
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when definitions cannot be fetched
|
|
22
|
+
class DefinitionsError < Error; end
|
|
23
|
+
|
|
24
|
+
# Raised when feature evaluation fails
|
|
25
|
+
class EvaluationError < Error
|
|
26
|
+
attr_reader :feature_key
|
|
27
|
+
|
|
28
|
+
def initialize(message, feature_key: nil)
|
|
29
|
+
super(message)
|
|
30
|
+
@feature_key = feature_key
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Raised when snapshot operations fail
|
|
35
|
+
class SnapshotError < Error; end
|
|
36
|
+
|
|
37
|
+
# Raised when signed definitions verification fails
|
|
38
|
+
class SignatureError < Error; end
|
|
39
|
+
|
|
40
|
+
# Raised when the client is not initialized
|
|
41
|
+
class NotInitializedError < Error
|
|
42
|
+
def initialize(message = "Toggly client is not initialized")
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raised when a feature is not found
|
|
48
|
+
class FeatureNotFoundError < Error
|
|
49
|
+
attr_reader :feature_key
|
|
50
|
+
|
|
51
|
+
def initialize(feature_key)
|
|
52
|
+
@feature_key = feature_key
|
|
53
|
+
super("Feature not found: #{feature_key}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Engine for evaluating feature flags.
|
|
5
|
+
#
|
|
6
|
+
# Processes feature definitions and their rules to determine
|
|
7
|
+
# if a feature should be enabled for a given context.
|
|
8
|
+
class EvaluationEngine
|
|
9
|
+
# @param registry [Registry] Evaluator registry
|
|
10
|
+
# @param logger [Logger, nil] Optional logger
|
|
11
|
+
def initialize(registry: nil, logger: nil)
|
|
12
|
+
@registry = registry || Registry.new
|
|
13
|
+
@logger = logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Evaluate a feature for a context
|
|
17
|
+
#
|
|
18
|
+
# @param definition [FeatureDefinition] The feature definition
|
|
19
|
+
# @param context [Context, nil] The evaluation context
|
|
20
|
+
# @return [Boolean] Whether the feature is enabled
|
|
21
|
+
def evaluate(definition, context = nil)
|
|
22
|
+
return false unless definition
|
|
23
|
+
|
|
24
|
+
# If feature is globally disabled, return false
|
|
25
|
+
return false unless definition.enabled
|
|
26
|
+
|
|
27
|
+
# If no rules, feature is simply on/off based on enabled flag
|
|
28
|
+
return definition.enabled unless definition.rules?
|
|
29
|
+
|
|
30
|
+
# Evaluate rules in order
|
|
31
|
+
evaluate_rules(definition, context)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Evaluate with detailed result
|
|
35
|
+
#
|
|
36
|
+
# @param definition [FeatureDefinition] The feature definition
|
|
37
|
+
# @param context [Context, nil] The evaluation context
|
|
38
|
+
# @return [EvaluationResult] Detailed evaluation result
|
|
39
|
+
def evaluate_with_details(definition, context = nil)
|
|
40
|
+
result = EvaluationResult.new(
|
|
41
|
+
feature_key: definition&.feature_key,
|
|
42
|
+
enabled: false,
|
|
43
|
+
reason: "unknown"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
unless definition
|
|
47
|
+
result.reason = "feature_not_found"
|
|
48
|
+
return result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unless definition.enabled
|
|
52
|
+
result.reason = "globally_disabled"
|
|
53
|
+
return result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless definition.rules?
|
|
57
|
+
result.enabled = true
|
|
58
|
+
result.reason = "globally_enabled"
|
|
59
|
+
return result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
result.enabled = evaluate_rules(definition, context, result)
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Registry]
|
|
67
|
+
attr_reader :registry
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def evaluate_rules(definition, context, result = nil)
|
|
72
|
+
definition.rules.each_with_index do |rule, index|
|
|
73
|
+
rule_type = rule["type"] || rule[:type] || "always_on"
|
|
74
|
+
evaluator = @registry.get(rule_type)
|
|
75
|
+
|
|
76
|
+
unless evaluator
|
|
77
|
+
log_warn("Unknown evaluator type: #{rule_type} for feature #{definition.feature_key}")
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
evaluation = evaluator.evaluate(rule, context, feature_key: definition.feature_key)
|
|
83
|
+
|
|
84
|
+
# nil means continue to next rule
|
|
85
|
+
next if evaluation.nil?
|
|
86
|
+
|
|
87
|
+
if result
|
|
88
|
+
result.matched_rule = rule
|
|
89
|
+
result.matched_rule_index = index
|
|
90
|
+
result.reason = "rule_matched"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return evaluation
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
log_error("Error evaluating rule #{index} for #{definition.feature_key}: #{e.message}")
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# No rules matched, default to enabled (since the feature itself is enabled)
|
|
101
|
+
result&.reason = "default_enabled"
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def log_warn(message)
|
|
106
|
+
@logger&.warn("[Toggly] #{message}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def log_error(message)
|
|
110
|
+
@logger&.error("[Toggly] #{message}")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Result of a feature evaluation with details
|
|
115
|
+
class EvaluationResult
|
|
116
|
+
attr_accessor :feature_key, :enabled, :reason, :matched_rule, :matched_rule_index
|
|
117
|
+
|
|
118
|
+
def initialize(feature_key:, enabled:, reason:)
|
|
119
|
+
@feature_key = feature_key
|
|
120
|
+
@enabled = enabled
|
|
121
|
+
@reason = reason
|
|
122
|
+
@matched_rule = nil
|
|
123
|
+
@matched_rule_index = nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_h
|
|
127
|
+
{
|
|
128
|
+
feature_key: @feature_key,
|
|
129
|
+
enabled: @enabled,
|
|
130
|
+
reason: @reason,
|
|
131
|
+
matched_rule: @matched_rule,
|
|
132
|
+
matched_rule_index: @matched_rule_index
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator that always returns false.
|
|
6
|
+
#
|
|
7
|
+
# Used for features that should be disabled for everyone.
|
|
8
|
+
class AlwaysOff < Base
|
|
9
|
+
def self.type
|
|
10
|
+
"always_off"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param rule [Hash] The rule configuration (ignored)
|
|
14
|
+
# @param context [Context] The evaluation context (ignored)
|
|
15
|
+
# @param feature_key [String] The feature key
|
|
16
|
+
# @return [Boolean] Always returns false
|
|
17
|
+
def evaluate(_rule, _context, feature_key: nil)
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator that always returns true.
|
|
6
|
+
#
|
|
7
|
+
# Used for features that should be enabled for everyone.
|
|
8
|
+
class AlwaysOn < Base
|
|
9
|
+
def self.type
|
|
10
|
+
"always_on"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param rule [Hash] The rule configuration (ignored)
|
|
14
|
+
# @param context [Context] The evaluation context (ignored)
|
|
15
|
+
# @param feature_key [String] The feature key
|
|
16
|
+
# @return [Boolean] Always returns true
|
|
17
|
+
def evaluate(_rule, _context, feature_key: nil)
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Base class for feature evaluators.
|
|
6
|
+
#
|
|
7
|
+
# Evaluators determine whether a feature should be enabled
|
|
8
|
+
# based on rules and context.
|
|
9
|
+
class Base
|
|
10
|
+
# @return [String] Evaluator type identifier
|
|
11
|
+
def self.type
|
|
12
|
+
raise NotImplementedError, "Subclass must implement .type"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Evaluate a rule against a context
|
|
16
|
+
#
|
|
17
|
+
# @param rule [Hash] The rule configuration
|
|
18
|
+
# @param context [Context] The evaluation context
|
|
19
|
+
# @param feature_key [String] The feature key (for logging)
|
|
20
|
+
# @return [Boolean, nil] true/false for match, nil to continue evaluation
|
|
21
|
+
def evaluate(rule, context, feature_key: nil)
|
|
22
|
+
raise NotImplementedError, "Subclass must implement #evaluate"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if this evaluator handles a rule type
|
|
26
|
+
#
|
|
27
|
+
# @param rule_type [String] The rule type
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def handles?(rule_type)
|
|
30
|
+
rule_type.to_s.downcase == self.class.type.to_s.downcase
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
# Get a value from rule with fallback
|
|
36
|
+
#
|
|
37
|
+
# @param rule [Hash] The rule
|
|
38
|
+
# @param key [String, Symbol] The key to lookup
|
|
39
|
+
# @param default [Object] Default value
|
|
40
|
+
# @return [Object]
|
|
41
|
+
def rule_value(rule, key, default = nil)
|
|
42
|
+
rule[key.to_s] || rule[key.to_sym] || default
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Log evaluation result (if logger available)
|
|
46
|
+
#
|
|
47
|
+
# @param feature_key [String] The feature key
|
|
48
|
+
# @param result [Boolean] The evaluation result
|
|
49
|
+
# @param reason [String] The reason for the result
|
|
50
|
+
def log_evaluation(feature_key, result, reason)
|
|
51
|
+
# Subclasses can override to add logging
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for contextual targeting based on traits.
|
|
6
|
+
#
|
|
7
|
+
# Supports various comparison operators on trait values.
|
|
8
|
+
class ContextualTargeting < Base
|
|
9
|
+
# Supported operators
|
|
10
|
+
OPERATORS = %w[
|
|
11
|
+
eq ne gt gte lt lte
|
|
12
|
+
contains not_contains
|
|
13
|
+
starts_with ends_with
|
|
14
|
+
in not_in
|
|
15
|
+
matches
|
|
16
|
+
exists not_exists
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def self.type
|
|
20
|
+
"contextual"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Evaluate contextual targeting rule
|
|
24
|
+
#
|
|
25
|
+
# @param rule [Hash] Rule with "conditions" array
|
|
26
|
+
# @param context [Context] Evaluation context with traits
|
|
27
|
+
# @param feature_key [String] The feature key
|
|
28
|
+
# @return [Boolean, nil] True if all conditions match, nil if no conditions
|
|
29
|
+
def evaluate(rule, context, feature_key: nil)
|
|
30
|
+
conditions = Array(rule_value(rule, "conditions"))
|
|
31
|
+
return nil if conditions.empty?
|
|
32
|
+
return nil unless context
|
|
33
|
+
|
|
34
|
+
match_type = rule_value(rule, "matchType") || rule_value(rule, "match_type") || "all"
|
|
35
|
+
|
|
36
|
+
if match_type == "any"
|
|
37
|
+
conditions.any? { |condition| evaluate_condition(condition, context) }
|
|
38
|
+
else
|
|
39
|
+
conditions.all? { |condition| evaluate_condition(condition, context) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def evaluate_condition(condition, context)
|
|
46
|
+
trait_key = condition["trait"] || condition["key"] || condition["attribute"]
|
|
47
|
+
return false unless trait_key
|
|
48
|
+
|
|
49
|
+
operator = (condition["operator"] || condition["op"] || "eq").to_s.downcase
|
|
50
|
+
expected = condition["value"] || condition["values"]
|
|
51
|
+
|
|
52
|
+
actual = context.trait(trait_key)
|
|
53
|
+
|
|
54
|
+
case operator
|
|
55
|
+
when "eq", "equals", "=="
|
|
56
|
+
compare_equal(actual, expected)
|
|
57
|
+
when "ne", "not_equals", "!="
|
|
58
|
+
!compare_equal(actual, expected)
|
|
59
|
+
when "gt", ">"
|
|
60
|
+
compare_numeric(actual, expected) { |a, e| a > e }
|
|
61
|
+
when "gte", ">="
|
|
62
|
+
compare_numeric(actual, expected) { |a, e| a >= e }
|
|
63
|
+
when "lt", "<"
|
|
64
|
+
compare_numeric(actual, expected) { |a, e| a < e }
|
|
65
|
+
when "lte", "<="
|
|
66
|
+
compare_numeric(actual, expected) { |a, e| a <= e }
|
|
67
|
+
when "contains"
|
|
68
|
+
actual.to_s.include?(expected.to_s)
|
|
69
|
+
when "not_contains"
|
|
70
|
+
!actual.to_s.include?(expected.to_s)
|
|
71
|
+
when "starts_with"
|
|
72
|
+
actual.to_s.start_with?(expected.to_s)
|
|
73
|
+
when "ends_with"
|
|
74
|
+
actual.to_s.end_with?(expected.to_s)
|
|
75
|
+
when "in"
|
|
76
|
+
Array(expected).map(&:to_s).include?(actual.to_s)
|
|
77
|
+
when "not_in"
|
|
78
|
+
!Array(expected).map(&:to_s).include?(actual.to_s)
|
|
79
|
+
when "matches", "regex"
|
|
80
|
+
actual.to_s.match?(Regexp.new(expected.to_s))
|
|
81
|
+
when "exists"
|
|
82
|
+
context.trait?(trait_key)
|
|
83
|
+
when "not_exists"
|
|
84
|
+
!context.trait?(trait_key)
|
|
85
|
+
else
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
rescue RegexpError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def compare_equal(actual, expected)
|
|
93
|
+
return actual.to_s.downcase == expected.to_s.downcase if actual.is_a?(String) || expected.is_a?(String)
|
|
94
|
+
|
|
95
|
+
actual == expected
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def compare_numeric(actual, expected)
|
|
99
|
+
actual_num = to_number(actual)
|
|
100
|
+
expected_num = to_number(expected)
|
|
101
|
+
|
|
102
|
+
return false if actual_num.nil? || expected_num.nil?
|
|
103
|
+
|
|
104
|
+
yield(actual_num, expected_num)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def to_number(value)
|
|
108
|
+
return value if value.is_a?(Numeric)
|
|
109
|
+
|
|
110
|
+
Float(value)
|
|
111
|
+
rescue ArgumentError, TypeError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|