togul-flags 2.3.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 +42 -0
- data/lib/togul/client.rb +125 -0
- data/lib/togul/config.rb +34 -0
- data/lib/togul/error.rb +13 -0
- data/lib/togul/stream_client.rb +82 -0
- data/lib/togul.rb +6 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1e894def387719430bcc555e33a1a557578d4847fc8d93014a0062dd1cc759d0
|
|
4
|
+
data.tar.gz: 5167ffb027245c1854b7d3b43e24df401161468486eccd44f20644802873ced5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 174d9e6926d47e5d2b59e99ce490abe9e1d249f76010be2d6e18232ddd4f17b3c92e5963b9daf1fcfeaaa38d06a574c07cd1aa8944a70cfbdea4520ac9f45fc8
|
|
7
|
+
data.tar.gz: 125ff020c7bd0d185589f34168292ccae2697b8092e9bb4a53301c30687e0243c0a315c6b76c43d7616661f371d2e66492578184c17ff8c024d27fde12b8c7ac
|
data/lib/togul/cache.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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 [Boolean, nil] cached value or nil on miss/expiry
|
|
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
|
+
entry[:value]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set(key, value)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@store[key] = {
|
|
25
|
+
value: value,
|
|
26
|
+
expires_at: Time.now.to_f + @ttl
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def flush
|
|
32
|
+
@mutex.synchronize { @store.clear }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def invalidate_flag(flag_key)
|
|
36
|
+
prefix = "#{flag_key}:"
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@store.delete_if { |key, _| key.start_with?(prefix) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/togul/client.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
cache_key = build_cache_key(key, context)
|
|
23
|
+
|
|
24
|
+
cached = @cache.get(cache_key)
|
|
25
|
+
return cached unless cached.nil?
|
|
26
|
+
|
|
27
|
+
value = evaluate(key, context)
|
|
28
|
+
@cache.set(cache_key, value)
|
|
29
|
+
value
|
|
30
|
+
rescue StandardError
|
|
31
|
+
case @config.fallback_mode
|
|
32
|
+
when :fail_open then true
|
|
33
|
+
else false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Clear all cached flag values.
|
|
38
|
+
def invalidate_cache
|
|
39
|
+
@cache.flush
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Clear a specific flag from cache.
|
|
43
|
+
def invalidate_flag(key)
|
|
44
|
+
@cache.invalidate_flag(key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Start SSE stream for real-time cache invalidation.
|
|
48
|
+
def stream
|
|
49
|
+
@stream_client ||= StreamClient.new(@config, @cache)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Register a listener for cache invalidation events.
|
|
53
|
+
def on_cache_invalidated(&block)
|
|
54
|
+
stream.on_cache_invalidated(&block)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def evaluate(key, context)
|
|
60
|
+
raise Error.new('API key is required') if @config.api_key.empty?
|
|
61
|
+
|
|
62
|
+
last_error = nil
|
|
63
|
+
|
|
64
|
+
@config.retry_count.times do |attempt|
|
|
65
|
+
sleep(attempt * 0.1) if attempt > 0
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
uri = URI("#{@config.base_url}/api/v1/evaluate")
|
|
69
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
+
http.use_ssl = uri.scheme == 'https'
|
|
71
|
+
http.open_timeout = @config.timeout
|
|
72
|
+
http.read_timeout = @config.timeout
|
|
73
|
+
|
|
74
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
75
|
+
request['Content-Type'] = 'application/json'
|
|
76
|
+
request['X-API-Key'] = @config.api_key
|
|
77
|
+
|
|
78
|
+
request.body = JSON.generate({
|
|
79
|
+
flag_key: key,
|
|
80
|
+
environment_key: @config.environment,
|
|
81
|
+
context: context
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
response = http.request(request)
|
|
85
|
+
|
|
86
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
87
|
+
last_error = build_api_error(response)
|
|
88
|
+
raise last_error unless should_retry?(response.code.to_i)
|
|
89
|
+
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
body = JSON.parse(response.body)
|
|
94
|
+
return body['value'] == true
|
|
95
|
+
rescue Error
|
|
96
|
+
raise
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
last_error = e
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise Error.new("all retries failed: #{last_error}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_cache_key(key, context)
|
|
106
|
+
serialized_context = context.sort.map { |context_key, value| "#{context_key}=#{value}" }
|
|
107
|
+
([key, @config.environment] + serialized_context).join(':')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def should_retry?(status_code)
|
|
111
|
+
status_code == 429 || status_code >= 500
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_api_error(response)
|
|
115
|
+
body = {}
|
|
116
|
+
body = JSON.parse(response.body) unless response.body.to_s.empty?
|
|
117
|
+
|
|
118
|
+
Error.new(
|
|
119
|
+
body['message'] || "unexpected status #{response.code}",
|
|
120
|
+
status_code: response.code.to_i,
|
|
121
|
+
error_code: body['code']
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
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,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,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: togul-flags
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 2.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Togul
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-14 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/stream_client.rb
|
|
54
|
+
homepage: https://github.com/togulapp/togul-ruby
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
source_code_uri: https://github.com/togulapp/togul-ruby
|
|
59
|
+
post_install_message:
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.0'
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 3.5.10
|
|
75
|
+
signing_key:
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: Ruby SDK for Togul Feature Flag Service
|
|
78
|
+
test_files: []
|