togglecraft 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/CHANGELOG.md +108 -0
- data/LICENSE +18 -0
- data/README.md +347 -0
- data/lib/togglecraft/cache.rb +151 -0
- data/lib/togglecraft/cache_adapters/memory_adapter.rb +54 -0
- data/lib/togglecraft/client.rb +568 -0
- data/lib/togglecraft/connection_pool.rb +134 -0
- data/lib/togglecraft/evaluator.rb +309 -0
- data/lib/togglecraft/shared_sse_connection.rb +266 -0
- data/lib/togglecraft/sse_connection.rb +296 -0
- data/lib/togglecraft/utils.rb +179 -0
- data/lib/togglecraft/version.rb +5 -0
- data/lib/togglecraft.rb +19 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/togglecraft/cache/memory_adapter_spec.rb +128 -0
- data/spec/togglecraft/cache_spec.rb +274 -0
- data/spec/togglecraft/client_spec.rb +728 -0
- data/spec/togglecraft/connection_pool_spec.rb +178 -0
- data/spec/togglecraft/evaluator_spec.rb +443 -0
- data/spec/togglecraft/shared_sse_connection_spec.rb +585 -0
- data/spec/togglecraft/sse_connection_spec.rb +691 -0
- data/spec/togglecraft/utils_spec.rb +506 -0
- metadata +151 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module ToggleCraft
|
|
7
|
+
# SSE (Server-Sent Events) connection manager
|
|
8
|
+
# Manages persistent connection to ToggleCraft SSE server with heartbeat
|
|
9
|
+
class SSEConnection
|
|
10
|
+
attr_reader :url, :sdk_key, :connected, :connection_id
|
|
11
|
+
|
|
12
|
+
# Initialize SSE connection
|
|
13
|
+
# @param url [String] SSE endpoint URL
|
|
14
|
+
# @param sdk_key [String] SDK key for authentication
|
|
15
|
+
# @param options [Hash] Connection options
|
|
16
|
+
def initialize(url:, sdk_key:, **options)
|
|
17
|
+
@url = url.sub(%r{/cable$}, '') # Remove /cable if present
|
|
18
|
+
@sdk_key = sdk_key
|
|
19
|
+
@options = {
|
|
20
|
+
reconnect_interval: 1,
|
|
21
|
+
max_reconnect_interval: 30,
|
|
22
|
+
max_reconnect_attempts: 10,
|
|
23
|
+
slow_reconnect_interval: 60,
|
|
24
|
+
heartbeat_domain: 'https://togglecraft.io',
|
|
25
|
+
heartbeat_interval: 300, # 5 minutes
|
|
26
|
+
debug: false
|
|
27
|
+
}.merge(options)
|
|
28
|
+
|
|
29
|
+
@connected = false
|
|
30
|
+
@connection_id = nil
|
|
31
|
+
@reconnect_attempts = 0
|
|
32
|
+
@should_reconnect = true
|
|
33
|
+
|
|
34
|
+
# Callbacks
|
|
35
|
+
@on_message = options[:on_message] || proc {}
|
|
36
|
+
@on_connect = options[:on_connect] || proc {}
|
|
37
|
+
@on_disconnect = options[:on_disconnect] || proc {}
|
|
38
|
+
@on_error = options[:on_error] || proc {}
|
|
39
|
+
|
|
40
|
+
# Threads
|
|
41
|
+
@connection_thread = nil
|
|
42
|
+
@heartbeat_thread = nil
|
|
43
|
+
@heartbeat_timer = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Connect to SSE server
|
|
47
|
+
# @return [void]
|
|
48
|
+
def connect
|
|
49
|
+
return if @connected
|
|
50
|
+
|
|
51
|
+
@should_reconnect = true
|
|
52
|
+
start_connection
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Disconnect from SSE server
|
|
56
|
+
# @return [void]
|
|
57
|
+
def disconnect
|
|
58
|
+
log 'Disconnecting'
|
|
59
|
+
@should_reconnect = false
|
|
60
|
+
@connection_id = nil
|
|
61
|
+
stop_heartbeat
|
|
62
|
+
stop_connection
|
|
63
|
+
@connected = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reconnect to SSE server
|
|
67
|
+
# @return [void]
|
|
68
|
+
def reconnect
|
|
69
|
+
disconnect
|
|
70
|
+
@should_reconnect = true
|
|
71
|
+
@reconnect_attempts = 0
|
|
72
|
+
connect
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if connected to SSE server
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def connected?
|
|
78
|
+
@connected
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Start SSE connection in background thread
|
|
84
|
+
def start_connection
|
|
85
|
+
@connection_thread = Thread.new do
|
|
86
|
+
connect_sse
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
log "Connection error: #{e.message}"
|
|
89
|
+
handle_error(e)
|
|
90
|
+
handle_disconnection
|
|
91
|
+
end
|
|
92
|
+
@connection_thread.abort_on_exception = false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Stop connection thread
|
|
96
|
+
def stop_connection
|
|
97
|
+
return unless @connection_thread
|
|
98
|
+
|
|
99
|
+
@connection_thread.kill if @connection_thread.alive?
|
|
100
|
+
@connection_thread = nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Connect to SSE server using HTTP streaming
|
|
104
|
+
def connect_sse
|
|
105
|
+
# Build SSE URL with channel identifier
|
|
106
|
+
channel_identifier = JSON.generate({
|
|
107
|
+
channel: 'FlagsChannel',
|
|
108
|
+
sdk_key: @sdk_key
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
sse_url = "#{@url}/events?identifier=#{CGI.escape(channel_identifier)}"
|
|
112
|
+
log "Connecting to #{sse_url}"
|
|
113
|
+
|
|
114
|
+
HTTP.get(sse_url) do |response|
|
|
115
|
+
raise "HTTP #{response.status}: #{response.status.reason}" unless response.status.success?
|
|
116
|
+
|
|
117
|
+
@connected = true
|
|
118
|
+
@reconnect_attempts = 0
|
|
119
|
+
@on_connect.call
|
|
120
|
+
start_heartbeat
|
|
121
|
+
|
|
122
|
+
# Read SSE stream
|
|
123
|
+
buffer = ''
|
|
124
|
+
response.body.each do |chunk|
|
|
125
|
+
buffer += chunk
|
|
126
|
+
|
|
127
|
+
# Process complete SSE messages (ending with \n\n)
|
|
128
|
+
while buffer.include?("\n\n")
|
|
129
|
+
message, buffer = buffer.split("\n\n", 2)
|
|
130
|
+
process_sse_message(message)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
log "SSE error: #{e.message}"
|
|
136
|
+
raise
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Process SSE message
|
|
140
|
+
# @param message [String] Raw SSE message
|
|
141
|
+
def process_sse_message(message)
|
|
142
|
+
return if message.strip.empty?
|
|
143
|
+
|
|
144
|
+
# Parse SSE format: "event: type\ndata: {...}"
|
|
145
|
+
lines = message.split("\n")
|
|
146
|
+
event_type = nil
|
|
147
|
+
data = nil
|
|
148
|
+
|
|
149
|
+
lines.each do |line|
|
|
150
|
+
if line.start_with?('event:')
|
|
151
|
+
event_type = line.sub('event:', '').strip
|
|
152
|
+
elsif line.start_with?('data:')
|
|
153
|
+
data = line.sub('data:', '').strip
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
return unless data
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
parsed_data = JSON.parse(data, symbolize_names: true)
|
|
161
|
+
|
|
162
|
+
# Extract connection ID from server messages
|
|
163
|
+
if parsed_data[:connection_id] && @connection_id != parsed_data[:connection_id]
|
|
164
|
+
log "Connection ID received: #{parsed_data[:connection_id]}"
|
|
165
|
+
@connection_id = parsed_data[:connection_id]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
@on_message.call(parsed_data)
|
|
169
|
+
rescue JSON::ParserError => e
|
|
170
|
+
log "Failed to parse SSE data: #{e.message}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Handle disconnection and schedule reconnection
|
|
175
|
+
def handle_disconnection
|
|
176
|
+
return unless @connected
|
|
177
|
+
|
|
178
|
+
@connected = false
|
|
179
|
+
@connection_id = nil
|
|
180
|
+
stop_heartbeat
|
|
181
|
+
@on_disconnect.call
|
|
182
|
+
|
|
183
|
+
schedule_reconnection if @should_reconnect
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Handle errors
|
|
187
|
+
# @param error [Exception] The error that occurred
|
|
188
|
+
def handle_error(error)
|
|
189
|
+
@on_error.call(error)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Schedule reconnection with exponential backoff
|
|
193
|
+
def schedule_reconnection
|
|
194
|
+
# Hybrid approach: switch to slow mode after max attempts
|
|
195
|
+
if @reconnect_attempts >= @options[:max_reconnect_attempts]
|
|
196
|
+
interval = @options[:slow_reconnect_interval]
|
|
197
|
+
log "Max fast reconnection attempts reached, switching to slow mode (every #{interval}s)"
|
|
198
|
+
else
|
|
199
|
+
@reconnect_attempts += 1
|
|
200
|
+
interval = [@options[:reconnect_interval] * (2**(@reconnect_attempts - 1)),
|
|
201
|
+
@options[:max_reconnect_interval]].min
|
|
202
|
+
log "Scheduling reconnection attempt #{@reconnect_attempts}/" \
|
|
203
|
+
"#{@options[:max_reconnect_attempts]} in #{interval}s"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
Thread.new do
|
|
207
|
+
sleep interval
|
|
208
|
+
connect if @should_reconnect
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
log "Reconnection failed: #{e.message}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Start heartbeat timer
|
|
215
|
+
def start_heartbeat
|
|
216
|
+
stop_heartbeat # Clear any existing timer
|
|
217
|
+
|
|
218
|
+
@heartbeat_timer = Thread.new do
|
|
219
|
+
loop do
|
|
220
|
+
sleep @options[:heartbeat_interval]
|
|
221
|
+
|
|
222
|
+
# Add random jitter (0-30 seconds)
|
|
223
|
+
jitter = rand(0..30)
|
|
224
|
+
sleep jitter
|
|
225
|
+
|
|
226
|
+
send_heartbeat if @connected
|
|
227
|
+
end
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
log "Heartbeat thread error: #{e.message}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
@heartbeat_timer.abort_on_exception = false
|
|
233
|
+
log "Heartbeat started (interval: #{@options[:heartbeat_interval]}s with 0-30s jitter)"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Stop heartbeat timer
|
|
237
|
+
def stop_heartbeat
|
|
238
|
+
return unless @heartbeat_timer
|
|
239
|
+
|
|
240
|
+
@heartbeat_timer.kill if @heartbeat_timer.alive?
|
|
241
|
+
@heartbeat_timer = nil
|
|
242
|
+
log 'Heartbeat stopped'
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Send heartbeat via HTTP POST
|
|
246
|
+
# CRITICAL: Only send when connection is actually established
|
|
247
|
+
def send_heartbeat
|
|
248
|
+
unless @connected
|
|
249
|
+
log 'Skipping heartbeat - not connected'
|
|
250
|
+
return
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
unless @connection_id
|
|
254
|
+
log 'Skipping heartbeat - connection ID not yet received'
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
begin
|
|
259
|
+
heartbeat_url = "#{@options[:heartbeat_domain]}/api/v1/heartbeat"
|
|
260
|
+
log "Sending heartbeat to #{heartbeat_url}"
|
|
261
|
+
|
|
262
|
+
response = HTTP.timeout(10)
|
|
263
|
+
.headers(
|
|
264
|
+
'Content-Type' => 'application/json',
|
|
265
|
+
'X-SDK-Key' => @sdk_key,
|
|
266
|
+
'X-Connection-ID' => @connection_id
|
|
267
|
+
)
|
|
268
|
+
.post(heartbeat_url)
|
|
269
|
+
|
|
270
|
+
if response.status.success?
|
|
271
|
+
data = JSON.parse(response.body.to_s)
|
|
272
|
+
log "Heartbeat successful: #{data}"
|
|
273
|
+
elsif response.status.code == 404
|
|
274
|
+
# Connection not found on server - reconnect
|
|
275
|
+
log 'Connection not found on server, disconnecting and reconnecting'
|
|
276
|
+
disconnect
|
|
277
|
+
@should_reconnect = true
|
|
278
|
+
schedule_reconnection
|
|
279
|
+
else
|
|
280
|
+
log "Heartbeat failed: #{response.status}"
|
|
281
|
+
end
|
|
282
|
+
rescue StandardError => e
|
|
283
|
+
log "Heartbeat request failed: #{e.message}"
|
|
284
|
+
# Don't disconnect on heartbeat failure - might be temporary network issue
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Log debug messages
|
|
289
|
+
# @param message [String] Message to log
|
|
290
|
+
def log(message)
|
|
291
|
+
return unless @options[:debug]
|
|
292
|
+
|
|
293
|
+
puts "[ToggleCraft SSE] #{message}"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToggleCraft
|
|
4
|
+
module Utils
|
|
5
|
+
# Operators supported for rule evaluation
|
|
6
|
+
OPERATORS = %w[
|
|
7
|
+
equals not_equals contains not_contains starts_with ends_with
|
|
8
|
+
in not_in gt gte lt lte between regex
|
|
9
|
+
semver_eq semver_gt semver_gte semver_lt semver_lte
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
# Consistent hashing for percentage rollouts
|
|
13
|
+
# Returns a value between 0 and 100
|
|
14
|
+
# Mirrors the JavaScript implementation exactly for consistency
|
|
15
|
+
#
|
|
16
|
+
# @param key [String] The key to hash
|
|
17
|
+
# @return [Integer] A value between 0 and 100
|
|
18
|
+
def self.hash_key(key)
|
|
19
|
+
hash = 0
|
|
20
|
+
key.to_s.each_char do |char|
|
|
21
|
+
hash = ((hash << 5) - hash) + char.ord
|
|
22
|
+
hash &= hash # Convert to 32-bit integer
|
|
23
|
+
end
|
|
24
|
+
hash.abs % 100
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Deep get a nested property from a hash using dot notation
|
|
28
|
+
# Supports both string and symbol keys
|
|
29
|
+
#
|
|
30
|
+
# @param obj [Hash] The hash to query
|
|
31
|
+
# @param path [String] The path to the property (e.g., "user.email")
|
|
32
|
+
# @return [Object, nil] The value at the path or nil
|
|
33
|
+
def self.get_nested_property(obj, path)
|
|
34
|
+
return nil unless obj && path
|
|
35
|
+
return nil if path.to_s.strip.empty?
|
|
36
|
+
|
|
37
|
+
keys = path.to_s.split('.')
|
|
38
|
+
current = obj
|
|
39
|
+
|
|
40
|
+
keys.each do |key|
|
|
41
|
+
return nil if current.nil?
|
|
42
|
+
|
|
43
|
+
# Try both symbol and string keys
|
|
44
|
+
current = current[key.to_sym] || current[key.to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
current
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Compare semantic versions
|
|
51
|
+
# Returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2
|
|
52
|
+
#
|
|
53
|
+
# @param version1 [String] First version
|
|
54
|
+
# @param version2 [String] Second version
|
|
55
|
+
# @return [Integer] -1, 0, or 1
|
|
56
|
+
def self.compare_semver(version1, version2)
|
|
57
|
+
v1_parts = version1.to_s.split('.').map(&:to_i)
|
|
58
|
+
v2_parts = version2.to_s.split('.').map(&:to_i)
|
|
59
|
+
|
|
60
|
+
3.times do |i|
|
|
61
|
+
part1 = v1_parts[i] || 0
|
|
62
|
+
part2 = v2_parts[i] || 0
|
|
63
|
+
|
|
64
|
+
return 1 if part1 > part2
|
|
65
|
+
return -1 if part1 < part2
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Generate random jitter delay to prevent thundering herd
|
|
72
|
+
# When many clients receive a version update simultaneously, jitter ensures
|
|
73
|
+
# they don't all fetch the new version at exactly the same time
|
|
74
|
+
#
|
|
75
|
+
# @param max_jitter_ms [Integer] Maximum jitter in milliseconds
|
|
76
|
+
# @return [Integer] Random delay between 0 and max_jitter_ms
|
|
77
|
+
def self.generate_jitter(max_jitter_ms = 1500)
|
|
78
|
+
rand(0..max_jitter_ms)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Create a unique key for caching based on flag and context
|
|
82
|
+
#
|
|
83
|
+
# @param flag_key [String] The flag key
|
|
84
|
+
# @param context [Hash] The evaluation context
|
|
85
|
+
# @return [String] A unique cache key
|
|
86
|
+
def self.create_cache_key(flag_key, context)
|
|
87
|
+
user_id = context.dig(:user, :id) || context.dig(:user, :email) || 'anonymous'
|
|
88
|
+
"#{flag_key}:#{user_id}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Evaluate a condition operator
|
|
92
|
+
# Supports 14 different operators for flexible targeting rules
|
|
93
|
+
#
|
|
94
|
+
# @param attribute_value [Object] The actual value from context
|
|
95
|
+
# @param operator [String] The operator to use
|
|
96
|
+
# @param condition_values [Array] The values to compare against
|
|
97
|
+
# @return [Boolean] Whether the condition passes
|
|
98
|
+
def self.evaluate_operator(attribute_value, operator, condition_values)
|
|
99
|
+
# Handle nil/null values
|
|
100
|
+
return %w[not_equals not_in].include?(operator) if attribute_value.nil?
|
|
101
|
+
|
|
102
|
+
first_value = condition_values[0]
|
|
103
|
+
attribute_str = attribute_value.to_s.downcase
|
|
104
|
+
value_str = first_value.to_s.downcase
|
|
105
|
+
|
|
106
|
+
case operator
|
|
107
|
+
when 'equals'
|
|
108
|
+
attribute_str == value_str
|
|
109
|
+
|
|
110
|
+
when 'not_equals'
|
|
111
|
+
attribute_str != value_str
|
|
112
|
+
|
|
113
|
+
when 'contains'
|
|
114
|
+
attribute_str.include?(value_str)
|
|
115
|
+
|
|
116
|
+
when 'not_contains'
|
|
117
|
+
!attribute_str.include?(value_str)
|
|
118
|
+
|
|
119
|
+
when 'starts_with'
|
|
120
|
+
attribute_str.start_with?(value_str)
|
|
121
|
+
|
|
122
|
+
when 'ends_with'
|
|
123
|
+
attribute_str.end_with?(value_str)
|
|
124
|
+
|
|
125
|
+
when 'in'
|
|
126
|
+
condition_values.any? { |v| v.to_s.downcase == attribute_str }
|
|
127
|
+
|
|
128
|
+
when 'not_in'
|
|
129
|
+
condition_values.none? { |v| v.to_s.downcase == attribute_str }
|
|
130
|
+
|
|
131
|
+
when 'gt'
|
|
132
|
+
attribute_value.to_f > first_value.to_f
|
|
133
|
+
|
|
134
|
+
when 'gte'
|
|
135
|
+
attribute_value.to_f >= first_value.to_f
|
|
136
|
+
|
|
137
|
+
when 'lt'
|
|
138
|
+
attribute_value.to_f < first_value.to_f
|
|
139
|
+
|
|
140
|
+
when 'lte'
|
|
141
|
+
attribute_value.to_f <= first_value.to_f
|
|
142
|
+
|
|
143
|
+
when 'between'
|
|
144
|
+
return false if condition_values.length < 2
|
|
145
|
+
|
|
146
|
+
num = attribute_value.to_f
|
|
147
|
+
num.between?(condition_values[0].to_f, condition_values[1].to_f)
|
|
148
|
+
|
|
149
|
+
when 'regex'
|
|
150
|
+
begin
|
|
151
|
+
regex = Regexp.new(first_value.to_s)
|
|
152
|
+
regex.match?(attribute_value.to_s)
|
|
153
|
+
rescue RegexpError
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
when 'semver_eq'
|
|
158
|
+
compare_semver(attribute_value, first_value).zero?
|
|
159
|
+
|
|
160
|
+
when 'semver_gt'
|
|
161
|
+
compare_semver(attribute_value, first_value).positive?
|
|
162
|
+
|
|
163
|
+
when 'semver_gte'
|
|
164
|
+
compare_semver(attribute_value, first_value) >= 0
|
|
165
|
+
|
|
166
|
+
when 'semver_lt'
|
|
167
|
+
compare_semver(attribute_value, first_value).negative?
|
|
168
|
+
|
|
169
|
+
when 'semver_lte'
|
|
170
|
+
compare_semver(attribute_value, first_value) <= 0
|
|
171
|
+
|
|
172
|
+
else
|
|
173
|
+
# Unknown operator - log warning and return false
|
|
174
|
+
warn "[ToggleCraft] Unknown operator: #{operator}"
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/togglecraft.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
require 'http'
|
|
5
|
+
require 'cgi'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
require_relative 'togglecraft/version'
|
|
9
|
+
require_relative 'togglecraft/utils'
|
|
10
|
+
require_relative 'togglecraft/cache'
|
|
11
|
+
require_relative 'togglecraft/evaluator'
|
|
12
|
+
require_relative 'togglecraft/sse_connection'
|
|
13
|
+
require_relative 'togglecraft/shared_sse_connection'
|
|
14
|
+
require_relative 'togglecraft/connection_pool'
|
|
15
|
+
require_relative 'togglecraft/client'
|
|
16
|
+
|
|
17
|
+
module ToggleCraft
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'simplecov'
|
|
4
|
+
SimpleCov.start do
|
|
5
|
+
add_filter '/spec/'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
require 'togglecraft'
|
|
9
|
+
require 'webmock/rspec'
|
|
10
|
+
|
|
11
|
+
RSpec.configure do |config|
|
|
12
|
+
# Enable flags like --only-failures and --next-failure
|
|
13
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
14
|
+
|
|
15
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
|
16
|
+
config.disable_monkey_patching!
|
|
17
|
+
|
|
18
|
+
config.expect_with :rspec do |c|
|
|
19
|
+
c.syntax = :expect
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Disable external HTTP requests during tests
|
|
23
|
+
config.before(:each) do
|
|
24
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe ToggleCraft::CacheAdapters::MemoryAdapter do
|
|
4
|
+
let(:adapter) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#get' do
|
|
7
|
+
it 'returns nil for non-existent keys' do
|
|
8
|
+
expect(adapter.get('missing')).to be_nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'returns the stored value' do
|
|
12
|
+
adapter.set('key', 'value')
|
|
13
|
+
expect(adapter.get('key')).to eq('value')
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#set' do
|
|
18
|
+
it 'stores a value' do
|
|
19
|
+
adapter.set('key', 'value')
|
|
20
|
+
expect(adapter.get('key')).to eq('value')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'overwrites existing values' do
|
|
24
|
+
adapter.set('key', 'old')
|
|
25
|
+
adapter.set('key', 'new')
|
|
26
|
+
expect(adapter.get('key')).to eq('new')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'stores different types of values' do
|
|
30
|
+
adapter.set('string', 'text')
|
|
31
|
+
adapter.set('number', 123)
|
|
32
|
+
adapter.set('hash', { foo: 'bar' })
|
|
33
|
+
adapter.set('array', [1, 2, 3])
|
|
34
|
+
|
|
35
|
+
expect(adapter.get('string')).to eq('text')
|
|
36
|
+
expect(adapter.get('number')).to eq(123)
|
|
37
|
+
expect(adapter.get('hash')).to eq({ foo: 'bar' })
|
|
38
|
+
expect(adapter.get('array')).to eq([1, 2, 3])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#delete' do
|
|
43
|
+
it 'removes a value and returns true' do
|
|
44
|
+
adapter.set('key', 'value')
|
|
45
|
+
result = adapter.delete('key')
|
|
46
|
+
expect(result).to be true
|
|
47
|
+
expect(adapter.get('key')).to be_nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'returns false for non-existent keys' do
|
|
51
|
+
expect(adapter.delete('missing')).to be false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '#clear' do
|
|
56
|
+
it 'removes all values' do
|
|
57
|
+
adapter.set('key1', 'value1')
|
|
58
|
+
adapter.set('key2', 'value2')
|
|
59
|
+
adapter.set('key3', 'value3')
|
|
60
|
+
|
|
61
|
+
adapter.clear
|
|
62
|
+
|
|
63
|
+
expect(adapter.get('key1')).to be_nil
|
|
64
|
+
expect(adapter.get('key2')).to be_nil
|
|
65
|
+
expect(adapter.get('key3')).to be_nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe '#has?' do
|
|
70
|
+
it 'returns true for existing keys' do
|
|
71
|
+
adapter.set('key', 'value')
|
|
72
|
+
expect(adapter.has?('key')).to be true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'returns false for non-existent keys' do
|
|
76
|
+
expect(adapter.has?('missing')).to be false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'returns false after deletion' do
|
|
80
|
+
adapter.set('key', 'value')
|
|
81
|
+
adapter.delete('key')
|
|
82
|
+
expect(adapter.has?('key')).to be false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe '#keys' do
|
|
87
|
+
it 'returns an empty array when no keys exist' do
|
|
88
|
+
expect(adapter.keys).to eq([])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'returns all stored keys' do
|
|
92
|
+
adapter.set('key1', 'value1')
|
|
93
|
+
adapter.set('key2', 'value2')
|
|
94
|
+
adapter.set('key3', 'value3')
|
|
95
|
+
|
|
96
|
+
keys = adapter.keys
|
|
97
|
+
expect(keys).to contain_exactly('key1', 'key2', 'key3')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'does not include deleted keys' do
|
|
101
|
+
adapter.set('key1', 'value1')
|
|
102
|
+
adapter.set('key2', 'value2')
|
|
103
|
+
adapter.delete('key1')
|
|
104
|
+
|
|
105
|
+
keys = adapter.keys
|
|
106
|
+
expect(keys).to contain_exactly('key2')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe 'thread safety' do
|
|
111
|
+
it 'handles concurrent reads and writes' do
|
|
112
|
+
threads = 10.times.map do |i|
|
|
113
|
+
Thread.new do
|
|
114
|
+
100.times do |j|
|
|
115
|
+
key = "thread_#{i}_key_#{j}"
|
|
116
|
+
adapter.set(key, "value_#{j}")
|
|
117
|
+
expect(adapter.get(key)).to eq("value_#{j}")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
threads.each(&:join)
|
|
123
|
+
|
|
124
|
+
# Verify all values were stored correctly
|
|
125
|
+
expect(adapter.keys.length).to eq(1000)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|