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 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "togul/config"
4
+ require_relative "togul/cache"
5
+ require_relative "togul/error"
6
+ require_relative "togul/evaluate_result"
7
+ require_relative "togul/client"
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: []