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