featbit-server-sdk 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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatBit
4
+ class User
5
+ attr_reader :key, :name, :custom
6
+
7
+ def initialize(key, name: nil, custom: {})
8
+ @key = deep_freeze(deep_copy(key.to_s))
9
+ @name = deep_freeze(deep_copy(name)) unless name.nil?
10
+ @custom = deep_freeze(deep_copy(custom || {}).transform_keys(&:to_s))
11
+ freeze
12
+ rescue StandardError
13
+ @key = ""
14
+ @name = nil
15
+ @custom = {}.freeze
16
+ freeze
17
+ end
18
+
19
+ def valid?
20
+ !key.empty?
21
+ end
22
+
23
+ def [](attribute)
24
+ normalized = attribute.to_s.downcase
25
+ return key if %w[key keyid targeting_key].include?(normalized)
26
+ return name if normalized == "name"
27
+
28
+ custom[attribute.to_s] || custom[normalized]
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ "keyId" => key,
36
+ "name" => name,
37
+ "customizedProperties" => custom.map do |property_name, value|
38
+ { "name" => property_name, "value" => value.to_s }
39
+ end
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def deep_copy(value)
46
+ case value
47
+ when Hash then value.each_with_object({}) { |(key, item), copy| copy[key.to_s] = deep_copy(item) }
48
+ when Array then value.map { |item| deep_copy(item) }
49
+ when String then value.dup
50
+ else value
51
+ end
52
+ end
53
+
54
+ def deep_freeze(value)
55
+ case value
56
+ when Hash
57
+ value.each do |key, item|
58
+ deep_freeze(key)
59
+ deep_freeze(item)
60
+ end
61
+ when Array then value.each { |item| deep_freeze(item) }
62
+ end
63
+ value.freeze
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeatBit
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require "timeout"
7
+ require "websocket-client-simple"
8
+
9
+ module FeatBit
10
+ class WebSocketDataSynchronizer
11
+ ALPHABETS = { "0" => "Q", "1" => "B", "2" => "W", "3" => "S", "4" => "P",
12
+ "5" => "H", "6" => "D", "7" => "X", "8" => "Z", "9" => "U" }.freeze
13
+ PING_INTERVAL = 10.0
14
+
15
+ def initialize(options:, data_store:, status_provider:, on_flags_changed: nil, connector: nil)
16
+ @options = options
17
+ @data_store = data_store
18
+ @status_provider = status_provider
19
+ @on_flags_changed = on_flags_changed
20
+ @connector = connector || method(:connect)
21
+ @closed = false
22
+ @socket_mutex = Mutex.new
23
+ @lifecycle_mutex = Mutex.new
24
+ @thread = nil
25
+ @close_result = nil
26
+ end
27
+
28
+ def start
29
+ @lifecycle_mutex.synchronize do
30
+ return false if @closed || @thread&.alive?
31
+
32
+ @thread = Thread.new { run }
33
+ @thread.name = "featbit-websocket-sync" if @thread.respond_to?(:name=)
34
+ true
35
+ end
36
+ rescue StandardError => e
37
+ fail_status(e)
38
+ false
39
+ end
40
+
41
+ def close
42
+ @lifecycle_mutex.synchronize do
43
+ return @close_result unless @close_result.nil?
44
+
45
+ @closed = true
46
+ socket = @socket_mutex.synchronize { @socket }
47
+ socket_closed = safe_close_socket(socket)
48
+ @thread&.join(5) unless Thread.current.equal?(@thread)
49
+ @close_result = socket_closed && !@thread&.alive?
50
+ end
51
+ rescue StandardError => e
52
+ @options.logger&.warn("FeatBit synchronizer close failed: #{e.message}")
53
+ false
54
+ end
55
+
56
+ def process_message(message)
57
+ envelope = message.is_a?(String) ? JSON.parse(message) : message
58
+ return false unless fetch(envelope, "messageType") == "data-sync"
59
+
60
+ data = fetch(envelope, "data", {})
61
+ event_type = fetch(data, "eventType")
62
+ return false unless valid_data?(data, event_type)
63
+
64
+ old_keys = @data_store.all_flags.keys
65
+ if event_type == "full"
66
+ changed = @data_store.init(data)
67
+ changed_keys = changed ? old_keys | @data_store.all_flags.keys : []
68
+ else
69
+ changed, changed_keys = process_patch(data)
70
+ end
71
+
72
+ changed_keys.each { |key| safely_notify(key) }
73
+ return false unless changed
74
+
75
+ @status_provider.update(Status::READY)
76
+ true
77
+ rescue JSON::ParserError => e
78
+ @status_provider.update(Status::FAILED, message: "invalid data: #{e.message}")
79
+ false
80
+ rescue StandardError => e
81
+ fail_status(e)
82
+ false
83
+ end
84
+
85
+ private
86
+
87
+ def run
88
+ delay = @options.reconnect_delay
89
+ until @closed
90
+ socket = nil
91
+ begin
92
+ configured = false
93
+ socket = @connector.call(websocket_url, headers) do |connected_socket|
94
+ configure_socket(connected_socket)
95
+ configured = true
96
+ end
97
+ @socket_mutex.synchronize { @socket = socket }
98
+ break if @closed
99
+
100
+ configure_socket(socket) unless configured
101
+ delay = @options.reconnect_delay
102
+ next_ping = monotonic_time + PING_INTERVAL
103
+ until @closed || socket_closed?(socket)
104
+ if monotonic_time >= next_ping
105
+ socket.send(JSON.generate(messageType: "ping", data: nil))
106
+ next_ping = monotonic_time + PING_INTERVAL
107
+ end
108
+ interruptible_sleep(0.05)
109
+ end
110
+ break if @closed
111
+
112
+ @status_provider.update(Status::INTERRUPTED, message: "WebSocket disconnected")
113
+ interruptible_sleep(delay)
114
+ delay = [delay * 2, 30.0].min
115
+ rescue StandardError => e
116
+ fail_status(e, interrupted: true)
117
+ safe_close_socket(socket)
118
+ interruptible_sleep(delay) unless @closed
119
+ delay = [delay * 2, 30.0].min
120
+ ensure
121
+ safe_close_socket(socket) if @closed
122
+ @socket_mutex.synchronize { @socket = nil }
123
+ end
124
+ end
125
+ end
126
+
127
+ def connect(url, request_headers, &configure)
128
+ Timeout.timeout(@options.connect_timeout) do
129
+ WebSocket::Client::Simple.connect(url, headers: request_headers, &configure)
130
+ end
131
+ end
132
+
133
+ def configure_socket(socket)
134
+ open_handler = method(:handle_socket_open)
135
+ message_handler = method(:handle_socket_message)
136
+ error_handler = method(:handle_socket_error)
137
+ close_handler = method(:handle_socket_close)
138
+
139
+ socket.on(:open) { open_handler.call(socket) }
140
+ socket.on(:message) { |event| message_handler.call(socket, event) }
141
+ socket.on(:error) { |event| error_handler.call(socket, event) }
142
+ socket.on(:close) { |event| close_handler.call(event) }
143
+ end
144
+
145
+ def handle_socket_open(socket)
146
+ socket.send(JSON.generate(messageType: "data-sync", data: { timestamp: @data_store.version }))
147
+ rescue StandardError => e
148
+ fail_status(e)
149
+ end
150
+
151
+ def handle_socket_message(socket, event)
152
+ return if process_message(event.respond_to?(:data) ? event.data : event.to_s)
153
+
154
+ safe_close_socket(socket) unless @closed
155
+ end
156
+
157
+ def handle_socket_error(socket, event)
158
+ return if @closed
159
+
160
+ fail_status(event.respond_to?(:message) ? event.message : event, interrupted: true)
161
+ safe_close_socket(socket)
162
+ end
163
+
164
+ def handle_socket_close(_event)
165
+ @status_provider.update(Status::INTERRUPTED, message: "WebSocket closed") unless @closed
166
+ end
167
+
168
+ def socket_closed?(socket)
169
+ return socket.closed? if socket.respond_to?(:closed?)
170
+ return !socket.open? if socket.respond_to?(:open?)
171
+
172
+ false
173
+ rescue StandardError
174
+ true
175
+ end
176
+
177
+ def safe_close_socket(socket)
178
+ return true unless socket
179
+
180
+ socket.close != false
181
+ rescue StandardError => e
182
+ @options.logger&.warn("FeatBit WebSocket close failed: #{e.message}")
183
+ false
184
+ end
185
+
186
+ def interruptible_sleep(duration)
187
+ deadline = monotonic_time + duration.to_f
188
+ until @closed
189
+ remaining = deadline - monotonic_time
190
+ break unless remaining.positive?
191
+
192
+ sleep([remaining, 0.05].min)
193
+ end
194
+ end
195
+
196
+ def monotonic_time
197
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
198
+ end
199
+
200
+ def process_patch(data)
201
+ items = Array(fetch(data, "featureFlags", [])).map { |flag| [:flags, flag] }
202
+ items.concat(Array(fetch(data, "segments", [])).map { |segment| [:segments, segment] })
203
+ changed_keys = []
204
+ items.sort_by { |_kind, item| item_version(item) }.each do |kind, item|
205
+ applied = @data_store.upsert(kind, item, version: item_version(item))
206
+ if applied && kind == :flags
207
+ changed_keys << fetch(item, "key").to_s
208
+ elsif applied
209
+ changed_keys.concat(flags_referencing_segment(fetch(item, "id").to_s))
210
+ end
211
+ end
212
+ [true, changed_keys.uniq]
213
+ end
214
+
215
+ def valid_data?(data, event_type)
216
+ flags = fetch(data, "featureFlags")
217
+ segments = fetch(data, "segments")
218
+ data.is_a?(Hash) &&
219
+ %w[full patch].include?(event_type) &&
220
+ flags.is_a?(Array) && flags.all? { |flag| !fetch(flag, "key").to_s.empty? } &&
221
+ segments.is_a?(Array) && segments.all? { |segment| !fetch(segment, "id").to_s.empty? }
222
+ end
223
+
224
+ def flags_referencing_segment(segment_id)
225
+ @data_store.all_flags.each_with_object([]) do |(flag_key, flag), result|
226
+ referenced = Array(fetch(flag, "rules", [])).any? do |rule|
227
+ Array(fetch(rule, "conditions", [])).any? do |condition|
228
+ next false unless fetch(condition, "op").nil?
229
+
230
+ serialized = fetch(condition, "value", "[]")
231
+ ids = serialized.is_a?(String) ? JSON.parse(serialized) : serialized
232
+ Array(ids).map(&:to_s).include?(segment_id)
233
+ rescue JSON::ParserError
234
+ false
235
+ end
236
+ end
237
+ result << flag_key if referenced
238
+ end
239
+ end
240
+
241
+ def item_version(item)
242
+ explicit = fetch(item, "timestamp")
243
+ return explicit.to_i if explicit
244
+
245
+ updated = fetch(item, "updatedAt")
246
+ updated ? (Time.parse(updated.to_s).to_f * 1000).to_i : @data_store.version + 1
247
+ rescue StandardError
248
+ @data_store.version + 1
249
+ end
250
+
251
+ def websocket_url
252
+ "#{@options.streaming_uri}?token=#{build_token(@options.env_secret)}&type=server"
253
+ end
254
+
255
+ def headers
256
+ {
257
+ "Authorization" => @options.env_secret,
258
+ "User-Agent" => "featbit-ruby-server-sdk/#{FeatBit::VERSION}",
259
+ "Content-Type" => "application/json"
260
+ }
261
+ end
262
+
263
+ def build_token(secret)
264
+ text = secret.to_s.delete_suffix("=")
265
+ timestamp = (Time.now.to_f * 1000).round.to_s
266
+ timestamp_code = encode_number(timestamp, timestamp.length)
267
+ start = [SecureRandom.random_number([text.length, 1].max), 2].max
268
+ start = text.length if start > text.length
269
+ "#{encode_number(start, 3)}#{encode_number(timestamp_code.length, 2)}#{text[0, start]}#{timestamp_code}#{text[start..]}"
270
+ end
271
+
272
+ def encode_number(number, length)
273
+ padded = number.to_s.rjust([12, length].max, "0")
274
+ padded[-length, length].chars.map { |character| ALPHABETS.fetch(character) }.join
275
+ end
276
+
277
+ def safely_notify(flag_key)
278
+ @on_flags_changed&.call(flag_key)
279
+ rescue StandardError => e
280
+ @options.logger&.warn("FeatBit flag listener failed: #{e.message}")
281
+ end
282
+
283
+ def fail_status(error, interrupted: false)
284
+ message = error.respond_to?(:message) ? error.message : error.to_s
285
+ @options.logger&.warn("FeatBit WebSocket synchronization failed: #{message}")
286
+ @status_provider.update(interrupted ? Status::INTERRUPTED : Status::FAILED, message: message)
287
+ end
288
+
289
+ def fetch(hash, key, default = nil)
290
+ hash.is_a?(Hash) ? hash.fetch(key.to_s, hash.fetch(key.to_sym, default)) : default
291
+ end
292
+ end
293
+ end
data/lib/featbit.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "featbit/version"
4
+ require_relative "featbit/options"
5
+ require_relative "featbit/user"
6
+ require_relative "featbit/evaluation_detail"
7
+ require_relative "featbit/status"
8
+ require_relative "featbit/data_store"
9
+ require_relative "featbit/evaluator"
10
+ require_relative "featbit/event_processor"
11
+ require_relative "featbit/web_socket_data_synchronizer"
12
+ require_relative "featbit/client"
13
+
14
+ module FeatBit
15
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: featbit-server-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - FeatBit
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: websocket-client-simple
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
40
+ description: Official FeatBit Server-Side SDK for Ruby.
41
+ email:
42
+ - contact@featbit.co
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - CHANGELOG.md
48
+ - LICENSE
49
+ - README.md
50
+ - lib/featbit.rb
51
+ - lib/featbit/client.rb
52
+ - lib/featbit/data_store.rb
53
+ - lib/featbit/evaluation_detail.rb
54
+ - lib/featbit/evaluator.rb
55
+ - lib/featbit/event_processor.rb
56
+ - lib/featbit/options.rb
57
+ - lib/featbit/status.rb
58
+ - lib/featbit/user.rb
59
+ - lib/featbit/version.rb
60
+ - lib/featbit/web_socket_data_synchronizer.rb
61
+ homepage: https://github.com/featbit/featbit-ruby-server-sdk
62
+ licenses:
63
+ - Apache-2.0
64
+ metadata:
65
+ bug_tracker_uri: https://github.com/featbit/featbit-ruby-server-sdk/issues
66
+ source_code_uri: https://github.com/featbit/featbit-ruby-server-sdk
67
+ changelog_uri: https://github.com/featbit/featbit-ruby-server-sdk/blob/main/CHANGELOG.md
68
+ documentation_uri: https://github.com/featbit/featbit-ruby-server-sdk#readme
69
+ homepage_uri: https://featbit.co/
70
+ rubygems_mfa_required: 'true'
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.1'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.6.9
86
+ specification_version: 4
87
+ summary: FeatBit Server-Side SDK for Ruby
88
+ test_files: []