togul 2.4.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/lib/togul/cache.rb +46 -0
- data/lib/togul/client.rb +188 -0
- data/lib/togul/config.rb +34 -0
- data/lib/togul/error.rb +13 -0
- data/lib/togul/evaluate_result.rb +46 -0
- data/lib/togul/stream_client.rb +82 -0
- data/lib/togul.rb +7 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 75fde010ac0c727c425c169bff582437c313ddc71a06e6bd285cd3271848afde
|
|
4
|
+
data.tar.gz: 3fcf4a81648a4c4bf52538026fa17ef7ac4fda0e5bc2c34bc9caa74bad029a8a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b13234a1b51798a399e6cb7e9a80274c2ef26db810df4d0f609329c097cf90cd71bddd2a6b11d00dd1b565efc43f6ff75b2f99bd8c5105c4917735dd0ac860e3
|
|
7
|
+
data.tar.gz: 1723699c586897d12cb4b29a2c8f8bbfd9563daea8ad6e1c598c035eb2efd7cead5d2fd3e823ca7ad97ea416a830100eff7287a149fdaffa26eac79e3ee8f306
|
data/lib/togul/cache.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Togul
|
|
4
|
+
class Cache
|
|
5
|
+
def initialize(ttl:)
|
|
6
|
+
@ttl = ttl
|
|
7
|
+
@store = {}
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# @return [EvaluateResult, nil] cached result or nil on miss/expiry/stale
|
|
12
|
+
def get(key)
|
|
13
|
+
@mutex.synchronize do
|
|
14
|
+
entry = @store[key]
|
|
15
|
+
return nil unless entry
|
|
16
|
+
return nil if Time.now.to_f > entry[:expires_at]
|
|
17
|
+
|
|
18
|
+
result = entry[:value]
|
|
19
|
+
# Treat entries with blank value_type as stale (legacy/invalid format).
|
|
20
|
+
return nil if result.value_type.to_s.empty?
|
|
21
|
+
|
|
22
|
+
result
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def set(key, result)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@store[key] = {
|
|
29
|
+
value: result,
|
|
30
|
+
expires_at: Time.now.to_f + @ttl
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def flush
|
|
36
|
+
@mutex.synchronize { @store.clear }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def invalidate_flag(flag_key)
|
|
40
|
+
prefix = "#{flag_key}:"
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@store.delete_if { |key, _| key.start_with?(prefix) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/togul/client.rb
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Togul
|
|
8
|
+
class Client
|
|
9
|
+
# @param config [Togul::Config]
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
@cache = Cache.new(ttl: config.cache_ttl)
|
|
13
|
+
@stream_client = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Evaluate a feature flag.
|
|
17
|
+
#
|
|
18
|
+
# @param key [String] Flag key
|
|
19
|
+
# @param context [Hash<String, String>] User/request context
|
|
20
|
+
# @return [Boolean] Whether the flag is enabled
|
|
21
|
+
def enabled?(key, context = {})
|
|
22
|
+
evaluate_result(key, context).enabled?
|
|
23
|
+
rescue StandardError
|
|
24
|
+
case @config.fallback_mode
|
|
25
|
+
when :fail_open then true
|
|
26
|
+
else false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Evaluate a feature flag and return the full result with typed value accessors.
|
|
31
|
+
#
|
|
32
|
+
# @param key [String] Flag key
|
|
33
|
+
# @param context [Hash<String, String>] User/request context
|
|
34
|
+
# @return [Togul::EvaluateResult]
|
|
35
|
+
def evaluate_result(key, context = {})
|
|
36
|
+
cache_key = build_cache_key(key, context)
|
|
37
|
+
|
|
38
|
+
cached = @cache.get(cache_key)
|
|
39
|
+
return cached unless cached.nil?
|
|
40
|
+
|
|
41
|
+
result = evaluate(key, context)
|
|
42
|
+
@cache.set(cache_key, result)
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Evaluate a boolean flag.
|
|
47
|
+
#
|
|
48
|
+
# @param key [String] Flag key
|
|
49
|
+
# @param context [Hash<String, String>] User/request context
|
|
50
|
+
# @param fallback [Boolean] Value to return on error or type mismatch
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def evaluate_bool(key, context = {}, fallback: false)
|
|
53
|
+
evaluate_result(key, context).bool_value(fallback)
|
|
54
|
+
rescue StandardError
|
|
55
|
+
fallback
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Evaluate a string flag.
|
|
59
|
+
#
|
|
60
|
+
# @param key [String] Flag key
|
|
61
|
+
# @param context [Hash<String, String>] User/request context
|
|
62
|
+
# @param fallback [String] Value to return on error or type mismatch
|
|
63
|
+
# @return [String]
|
|
64
|
+
def evaluate_string(key, context = {}, fallback: '')
|
|
65
|
+
evaluate_result(key, context).string_value(fallback)
|
|
66
|
+
rescue StandardError
|
|
67
|
+
fallback
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Evaluate a number flag.
|
|
71
|
+
#
|
|
72
|
+
# @param key [String] Flag key
|
|
73
|
+
# @param context [Hash<String, String>] User/request context
|
|
74
|
+
# @param fallback [Float] Value to return on error or type mismatch
|
|
75
|
+
# @return [Float]
|
|
76
|
+
def evaluate_number(key, context = {}, fallback: 0.0)
|
|
77
|
+
evaluate_result(key, context).number_value(fallback)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
fallback
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Evaluate a JSON flag.
|
|
83
|
+
#
|
|
84
|
+
# @param key [String] Flag key
|
|
85
|
+
# @param context [Hash<String, String>] User/request context
|
|
86
|
+
# @param fallback [Object] Value to return on error or type mismatch
|
|
87
|
+
# @return [Object]
|
|
88
|
+
def evaluate_json(key, context = {}, fallback: nil)
|
|
89
|
+
evaluate_result(key, context).json_value(fallback)
|
|
90
|
+
rescue StandardError
|
|
91
|
+
fallback
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Clear all cached flag values.
|
|
95
|
+
def invalidate_cache
|
|
96
|
+
@cache.flush
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Clear a specific flag from cache.
|
|
100
|
+
def invalidate_flag(key)
|
|
101
|
+
@cache.invalidate_flag(key)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Start SSE stream for real-time cache invalidation.
|
|
105
|
+
def stream
|
|
106
|
+
@stream_client ||= StreamClient.new(@config, @cache)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Register a listener for cache invalidation events.
|
|
110
|
+
def on_cache_invalidated(&block)
|
|
111
|
+
stream.on_cache_invalidated(&block)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def evaluate(key, context)
|
|
117
|
+
raise Error.new('API key is required') if @config.api_key.empty?
|
|
118
|
+
|
|
119
|
+
last_error = nil
|
|
120
|
+
|
|
121
|
+
@config.retry_count.times do |attempt|
|
|
122
|
+
sleep(attempt * 0.1) if attempt > 0
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
uri = URI("#{@config.base_url}/api/v1/evaluate")
|
|
126
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
127
|
+
http.use_ssl = uri.scheme == 'https'
|
|
128
|
+
http.open_timeout = @config.timeout
|
|
129
|
+
http.read_timeout = @config.timeout
|
|
130
|
+
|
|
131
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
132
|
+
request['Content-Type'] = 'application/json'
|
|
133
|
+
request['X-API-Key'] = @config.api_key
|
|
134
|
+
|
|
135
|
+
request.body = JSON.generate({
|
|
136
|
+
flag_key: key,
|
|
137
|
+
environment_key: @config.environment,
|
|
138
|
+
context: context
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
response = http.request(request)
|
|
142
|
+
|
|
143
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
144
|
+
last_error = build_api_error(response)
|
|
145
|
+
raise last_error unless should_retry?(response.code.to_i)
|
|
146
|
+
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
body = JSON.parse(response.body)
|
|
151
|
+
return EvaluateResult.new(
|
|
152
|
+
flag_key: body['flag_key'] || key,
|
|
153
|
+
enabled: body['enabled'] == true,
|
|
154
|
+
value_type: body['value_type'].to_s,
|
|
155
|
+
raw_value: body['value'],
|
|
156
|
+
reason: body['reason'].to_s
|
|
157
|
+
)
|
|
158
|
+
rescue Error
|
|
159
|
+
raise
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
last_error = e
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
raise Error.new("all retries failed: #{last_error}")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_cache_key(key, context)
|
|
169
|
+
serialized_context = context.sort.map { |context_key, value| "#{context_key}=#{value}" }
|
|
170
|
+
([key, @config.environment] + serialized_context).join(':')
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def should_retry?(status_code)
|
|
174
|
+
status_code == 429 || status_code >= 500
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_api_error(response)
|
|
178
|
+
body = {}
|
|
179
|
+
body = JSON.parse(response.body) unless response.body.to_s.empty?
|
|
180
|
+
|
|
181
|
+
Error.new(
|
|
182
|
+
body['message'] || "unexpected status #{response.code}",
|
|
183
|
+
status_code: response.code.to_i,
|
|
184
|
+
error_code: body['code']
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/togul/config.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Togul
|
|
4
|
+
class Config
|
|
5
|
+
DEFAULT_BASE_URL = 'https://api.togul.io'
|
|
6
|
+
|
|
7
|
+
attr_reader :api_key, :environment, :timeout, :cache_ttl, :fallback_mode, :retry_count, :base_url
|
|
8
|
+
|
|
9
|
+
# @param environment [String] Environment key (e.g. "production")
|
|
10
|
+
# @param api_key [String] Environment API key for evaluate/stream requests
|
|
11
|
+
# @param timeout [Numeric] HTTP timeout in seconds
|
|
12
|
+
# @param cache_ttl [Integer] Cache TTL in seconds
|
|
13
|
+
# @param fallback_mode [Symbol] :fail_closed or :fail_open
|
|
14
|
+
# @param retry_count [Integer] Number of retry attempts
|
|
15
|
+
# @param base_url [String, nil] Override default base URL (optional)
|
|
16
|
+
def initialize(
|
|
17
|
+
environment:,
|
|
18
|
+
api_key: '',
|
|
19
|
+
timeout: 5,
|
|
20
|
+
cache_ttl: 30,
|
|
21
|
+
fallback_mode: :fail_closed,
|
|
22
|
+
retry_count: 2,
|
|
23
|
+
base_url: nil
|
|
24
|
+
)
|
|
25
|
+
@base_url = base_url&.chomp('/') || DEFAULT_BASE_URL
|
|
26
|
+
@api_key = api_key
|
|
27
|
+
@environment = environment
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@cache_ttl = cache_ttl
|
|
30
|
+
@fallback_mode = fallback_mode
|
|
31
|
+
@retry_count = retry_count
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/togul/error.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Togul
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :status_code, :error_code
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, status_code: nil, error_code: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@status_code = status_code
|
|
10
|
+
@error_code = error_code
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Togul
|
|
4
|
+
class EvaluateResult
|
|
5
|
+
attr_reader :flag_key, :enabled, :value_type, :reason
|
|
6
|
+
|
|
7
|
+
def initialize(flag_key:, enabled:, value_type:, raw_value:, reason:)
|
|
8
|
+
@flag_key = flag_key
|
|
9
|
+
@enabled = enabled
|
|
10
|
+
@value_type = value_type
|
|
11
|
+
@raw_value = raw_value
|
|
12
|
+
@reason = reason
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def enabled?
|
|
16
|
+
@enabled == true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def bool_value(fallback = false)
|
|
20
|
+
return fallback unless enabled? && @value_type == 'boolean'
|
|
21
|
+
return fallback unless @raw_value == true || @raw_value == false
|
|
22
|
+
|
|
23
|
+
@raw_value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def string_value(fallback = '')
|
|
27
|
+
return fallback unless enabled? && @value_type == 'string'
|
|
28
|
+
return fallback unless @raw_value.is_a?(String)
|
|
29
|
+
|
|
30
|
+
@raw_value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def number_value(fallback = 0.0)
|
|
34
|
+
return fallback unless enabled? && @value_type == 'number'
|
|
35
|
+
return fallback unless @raw_value.is_a?(Numeric)
|
|
36
|
+
|
|
37
|
+
@raw_value.to_f
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def json_value(fallback = nil)
|
|
41
|
+
return fallback unless enabled? && @value_type == 'json'
|
|
42
|
+
|
|
43
|
+
@raw_value.nil? ? fallback : @raw_value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Togul
|
|
8
|
+
class StreamClient
|
|
9
|
+
def initialize(config, cache)
|
|
10
|
+
@config = config
|
|
11
|
+
@cache = cache
|
|
12
|
+
@listeners = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def connect
|
|
16
|
+
backoff = 1
|
|
17
|
+
|
|
18
|
+
loop do
|
|
19
|
+
stream_once
|
|
20
|
+
rescue Error => e
|
|
21
|
+
raise if [401, 403].include?(e.status_code)
|
|
22
|
+
|
|
23
|
+
sleep(backoff)
|
|
24
|
+
backoff = [backoff * 2, 30].min
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_cache_invalidated(&block)
|
|
29
|
+
@listeners << block
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def stream_once
|
|
35
|
+
raise Error.new('API key is required') if @config.api_key.empty?
|
|
36
|
+
|
|
37
|
+
uri = URI("#{@config.base_url}/api/v1/stream")
|
|
38
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
39
|
+
http.use_ssl = uri.scheme == 'https'
|
|
40
|
+
|
|
41
|
+
request = Net::HTTP::Get.new(uri.path)
|
|
42
|
+
request['Accept'] = 'text/event-stream'
|
|
43
|
+
request['X-API-Key'] = @config.api_key
|
|
44
|
+
|
|
45
|
+
response = http.request(request)
|
|
46
|
+
|
|
47
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
48
|
+
raise Error.new("stream failed: #{response.code}", status_code: response.code.to_i)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
response.read_body do |chunk|
|
|
52
|
+
parse_sse(chunk)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_sse(buffer)
|
|
57
|
+
buffer.each_line do |line|
|
|
58
|
+
next unless line.start_with?('data: ')
|
|
59
|
+
|
|
60
|
+
data = line[6..].strip
|
|
61
|
+
event = JSON.parse(data)
|
|
62
|
+
handle_event(event)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def handle_event(event)
|
|
67
|
+
flag_key = event['flag_key'] || ''
|
|
68
|
+
|
|
69
|
+
if flag_key != ''
|
|
70
|
+
@cache.invalidate_flag(flag_key)
|
|
71
|
+
notify_listeners(flag_key)
|
|
72
|
+
else
|
|
73
|
+
@cache.flush
|
|
74
|
+
notify_listeners('')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def notify_listeners(flag_key)
|
|
79
|
+
@listeners.each { |listener| listener.call(flag_key) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/togul.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: togul
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 2.4.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Togul
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: json
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: net-http
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0'
|
|
41
|
+
description: Client library for evaluating feature flags from a Togul server with
|
|
42
|
+
TTL caching, retry, and fallback support.
|
|
43
|
+
email:
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- lib/togul.rb
|
|
49
|
+
- lib/togul/cache.rb
|
|
50
|
+
- lib/togul/client.rb
|
|
51
|
+
- lib/togul/config.rb
|
|
52
|
+
- lib/togul/error.rb
|
|
53
|
+
- lib/togul/evaluate_result.rb
|
|
54
|
+
- lib/togul/stream_client.rb
|
|
55
|
+
homepage: https://github.com/togulapp/togul-ruby
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
source_code_uri: https://github.com/togulapp/togul-ruby
|
|
60
|
+
post_install_message:
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.0'
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.5.10
|
|
76
|
+
signing_key:
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Ruby SDK for Togul Feature Flag Service
|
|
79
|
+
test_files: []
|