togul 3.0.1 → 3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b2551b63847f7928914d173351b85f54aa78f64a67552701273b9ef334ea0e2
4
- data.tar.gz: cb63352498c0f05520963a92fb0f8ea17759b91823a7538d9051134ffd91120b
3
+ metadata.gz: 5d53ead06f1bd25ad67767277f7f31cda93a4178a9e29129e5bc267da925ece6
4
+ data.tar.gz: 27a85c6db8f140b334c78c50bbc39809b524d99be02dc3513717dfa1787fd2b5
5
5
  SHA512:
6
- metadata.gz: 7272d609f48bd5100c6e9f6bf78b00d0df4bd0a517f1e96a2c097335d6319b66d81f0f7c8485ea3a9307c7868c5ce2c74cc59bee5e8971f4884fc4247a36e843
7
- data.tar.gz: 359137fd534f9dac13572955a506852ab9baa1d8c6e5a07289312075bb4d12656f3fd1e5c65dbb6df439342e18d41647e040e95a9e43ec5c20769b2010f787c3
6
+ metadata.gz: '04798172f985b9d886c146387de2548223040b382d7ccebbe332e3f55858061379ef3b86a8b024b421ba5749a7e73f6532dea5d55e3e7cce9bc0549431b0a692'
7
+ data.tar.gz: f5d508679f70eb795a576b8ffd7438ca6343dde309a7f138cd476880df2677adc0bfe9a688ce7182c3d8d1f580610efe63b73c5d7b2de6d8f4ca60b64a8474f2
data/lib/togul/client.rb CHANGED
@@ -11,6 +11,7 @@ module Togul
11
11
  @config = config
12
12
  @cache = Cache.new(ttl: config.cache_ttl)
13
13
  @stream_client = nil
14
+ @listeners = []
14
15
  end
15
16
 
16
17
  # Evaluate a feature flag and return the result mirroring the API response.
@@ -32,26 +33,34 @@ module Togul
32
33
  # Clear all cached flag values.
33
34
  def invalidate_cache
34
35
  @cache.flush
36
+ notify_listeners('')
35
37
  end
36
38
 
37
39
  # Clear a specific flag from cache.
38
40
  def invalidate_flag(key)
39
41
  @cache.invalidate_flag(key)
42
+ notify_listeners(key)
40
43
  end
41
44
 
42
45
  # Start the SSE stream in a background thread for real-time cache invalidation.
43
46
  # Subsequent calls are no-ops; the thread runs until the process exits.
44
47
  def start_stream
45
- @stream_client ||= StreamClient.new(@config, @cache)
48
+ unless @stream_client
49
+ @stream_client = StreamClient.new(@config, @cache)
50
+ # The stream invalidates the cache itself; forward so listeners fire
51
+ # for stream events exactly as for manual invalidation.
52
+ @stream_client.on_cache_invalidated { |flag_key| notify_listeners(flag_key) }
53
+ end
46
54
  @stream_thread ||= Thread.new { @stream_client.connect }
47
55
  nil
48
56
  end
49
57
 
50
- # Register a listener for cache invalidation events.
51
- # Call start_stream separately to begin receiving events.
58
+ # Register a listener for cache invalidation, called with the flag key (or
59
+ # "" when the whole cache was cleared) for both manual invalidation and
60
+ # stream events. Call start_stream separately to receive stream events.
52
61
  def on_cache_invalidated(&block)
53
- @stream_client ||= StreamClient.new(@config, @cache)
54
- @stream_client.on_cache_invalidated(&block)
62
+ @listeners << block
63
+ nil
55
64
  end
56
65
 
57
66
  private
@@ -108,6 +117,10 @@ module Togul
108
117
  raise Error.new("all retries failed: #{last_error}")
109
118
  end
110
119
 
120
+ def notify_listeners(flag_key)
121
+ @listeners.each { |listener| listener.call(flag_key) }
122
+ end
123
+
111
124
  def build_cache_key(key, context)
112
125
  serialized_context = context.sort.map { |context_key, value| "#{context_key}=#{value}" }
113
126
  ([key, @config.environment] + serialized_context).join(':')
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'open_feature/sdk'
6
+ require_relative '../togul'
7
+
8
+ module Togul
9
+ module OpenFeature
10
+ # OpenFeature provider backed by Togul::Client.
11
+ #
12
+ # Requires the optional "openfeature-sdk" gem and an explicit
13
+ # `require "togul/open_feature"`. Caching, retries and SSE invalidation all
14
+ # stay in Togul::Client; this class only adapts the single evaluate call to
15
+ # OpenFeature's typed resolvers and never raises. Cache invalidations are
16
+ # re-emitted as PROVIDER_CONFIGURATION_CHANGED. Mirrors the PHP provider
17
+ # (togul-php/src/OpenFeature/TogulProvider.php).
18
+ class Provider
19
+ include ::OpenFeature::SDK::Provider::EventEmitter
20
+
21
+ Reason = ::OpenFeature::SDK::Provider::Reason
22
+ ErrorCode = ::OpenFeature::SDK::Provider::ErrorCode
23
+ ResolutionDetails = ::OpenFeature::SDK::Provider::ResolutionDetails
24
+
25
+ attr_reader :metadata, :client
26
+
27
+ # @param client [Togul::Client]
28
+ # @param targeting_key_attribute [String] Context attribute the targeting
29
+ # key is sent as. Matches the default rule "bucket_by".
30
+ def initialize(client, targeting_key_attribute: 'user_id')
31
+ @client = client
32
+ @targeting_key_attribute = targeting_key_attribute
33
+ @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: 'Togul').freeze
34
+ @closed = false
35
+ client.on_cache_invalidated { |flag_key| on_invalidated(flag_key) }
36
+ end
37
+
38
+ def init(_evaluation_context = nil); end
39
+
40
+ # Stop forwarding invalidations. The client is left to its owner.
41
+ def shutdown
42
+ @closed = true
43
+ end
44
+
45
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
46
+ resolve(flag_key, default_value, evaluation_context, 'boolean') do |v|
47
+ [v == true || v == false, v]
48
+ end
49
+ end
50
+
51
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
52
+ resolve(flag_key, default_value, evaluation_context, 'string') { |v| [v.is_a?(String), v] }
53
+ end
54
+
55
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
56
+ resolve(flag_key, default_value, evaluation_context, 'number') { |v| [v.is_a?(Numeric), v] }
57
+ end
58
+
59
+ # Togul has a single "number" type; JSON parses "3.0" as a Float.
60
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
61
+ resolve(flag_key, default_value, evaluation_context, 'integer') do |v|
62
+ whole = v.is_a?(Integer) || (v.is_a?(Float) && v.finite? && v == v.floor)
63
+ [whole, whole ? v.to_i : nil]
64
+ end
65
+ end
66
+
67
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
68
+ resolve(flag_key, default_value, evaluation_context, 'float') do |v|
69
+ [v.is_a?(Numeric), v.is_a?(Numeric) ? v.to_f : nil]
70
+ end
71
+ end
72
+
73
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
74
+ resolve(flag_key, default_value, evaluation_context, 'object') do |v|
75
+ [v.is_a?(Hash) || v.is_a?(Array), v]
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def on_invalidated(flag_key)
82
+ return if @closed
83
+
84
+ details = { message: 'Togul cache invalidated' }
85
+ details[:flags_changed] = [flag_key] unless flag_key.to_s.empty?
86
+ emit_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, details)
87
+ end
88
+
89
+ # @yieldparam value [Object] the flag value
90
+ # @yieldreturn [Array(Boolean, Object)] [matches, coerced value]
91
+ def resolve(flag_key, default_value, evaluation_context, expected_type)
92
+ begin
93
+ result = @client.evaluate(flag_key, togul_context(evaluation_context))
94
+ rescue Togul::Error => e
95
+ not_found = e.status_code == 404 && e.error_code == 'evaluate.flag_not_found'
96
+ return error(default_value, not_found ? ErrorCode::FLAG_NOT_FOUND : ErrorCode::GENERAL, e.message)
97
+ rescue StandardError => e
98
+ return error(default_value, ErrorCode::GENERAL, e.message)
99
+ end
100
+
101
+ # OpenFeature spec: a disabled flag resolves to the caller's default.
102
+ return ResolutionDetails.new(value: default_value, reason: Reason::DISABLED) unless result.enabled?
103
+
104
+ # A json flag created without a default stores null: nothing to serve.
105
+ return ResolutionDetails.new(value: default_value, reason: map_reason(result.reason)) if result.value.nil?
106
+
107
+ matches, value = yield(result.value)
108
+ unless matches
109
+ return error(default_value, ErrorCode::TYPE_MISMATCH,
110
+ "Flag \"#{flag_key}\" has value_type \"#{result.value_type}\", requested #{expected_type}")
111
+ end
112
+
113
+ ResolutionDetails.new(value: value, reason: map_reason(result.reason))
114
+ end
115
+
116
+ def map_reason(reason)
117
+ case reason
118
+ when 'rule_match' then Reason::TARGETING_MATCH
119
+ when 'default' then Reason::DEFAULT
120
+ when 'disabled' then Reason::DISABLED
121
+ else Reason::UNKNOWN
122
+ end
123
+ end
124
+
125
+ def error(default_value, code, message)
126
+ ResolutionDetails.new(value: default_value, reason: Reason::ERROR, error_code: code, error_message: message)
127
+ end
128
+
129
+ # Togul evaluates against a flat Hash<String, String>, and Togul::Client
130
+ # builds its cache key by concatenating those values.
131
+ def togul_context(evaluation_context)
132
+ return {} if evaluation_context.nil?
133
+
134
+ targeting_key_field = ::OpenFeature::SDK::EvaluationContext::TARGETING_KEY
135
+ out = {}
136
+ evaluation_context.fields.each do |key, value|
137
+ next if key == targeting_key_field
138
+
139
+ string_value = stringify_attribute(value)
140
+ out[key.to_s] = string_value unless string_value.nil?
141
+ end
142
+
143
+ # An explicitly set attribute wins over the targeting key.
144
+ targeting_key = evaluation_context.targeting_key
145
+ if targeting_key && !targeting_key.to_s.empty? && !out.key?(@targeting_key_attribute)
146
+ out[@targeting_key_attribute] = targeting_key.to_s
147
+ end
148
+ out
149
+ end
150
+
151
+ # Rules compare with plain string equality ("eq", "in", ...), so every
152
+ # format must match what users type in the dashboard. nil drops the
153
+ # attribute.
154
+ def stringify_attribute(value)
155
+ case value
156
+ when nil then nil
157
+ when true, false then value.to_s
158
+ when Integer then value.to_s
159
+ when Float
160
+ return nil unless value.finite?
161
+
162
+ value == value.floor ? value.to_i.to_s : value.to_s
163
+ when Time, DateTime then value.iso8601
164
+ when Hash, Array then JSON.generate(value)
165
+ else value.to_s
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
data/lib/togul.rb CHANGED
@@ -4,4 +4,5 @@ require_relative "togul/config"
4
4
  require_relative "togul/cache"
5
5
  require_relative "togul/error"
6
6
  require_relative "togul/evaluate_result"
7
+ require_relative "togul/stream_client"
7
8
  require_relative "togul/client"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: togul
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Togul
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-23 00:00:00.000000000 Z
11
+ date: 2026-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -38,6 +38,34 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: openfeature-sdk
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0.5'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '5.0'
41
69
  description: Client library for evaluating feature flags from a Togul server with
42
70
  TTL caching, retry, and fallback support.
43
71
  email:
@@ -51,6 +79,7 @@ files:
51
79
  - lib/togul/config.rb
52
80
  - lib/togul/error.rb
53
81
  - lib/togul/evaluate_result.rb
82
+ - lib/togul/open_feature.rb
54
83
  - lib/togul/stream_client.rb
55
84
  homepage: https://github.com/togulapp/togul-ruby
56
85
  licenses: