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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +201 -0
- data/README.md +252 -0
- data/lib/featbit/client.rb +282 -0
- data/lib/featbit/data_store.rb +188 -0
- data/lib/featbit/evaluation_detail.rb +12 -0
- data/lib/featbit/evaluator.rb +230 -0
- data/lib/featbit/event_processor.rb +225 -0
- data/lib/featbit/options.rb +107 -0
- data/lib/featbit/status.rb +103 -0
- data/lib/featbit/user.rb +66 -0
- data/lib/featbit/version.rb +5 -0
- data/lib/featbit/web_socket_data_synchronizer.rb +293 -0
- data/lib/featbit.rb +15 -0
- metadata +88 -0
data/lib/featbit/user.rb
ADDED
|
@@ -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,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: []
|