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 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
@@ -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
@@ -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
@@ -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,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,6 @@
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/client"
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: []