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 +4 -4
- data/lib/togul/client.rb +18 -5
- data/lib/togul/open_feature.rb +170 -0
- data/lib/togul.rb +1 -0
- metadata +31 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d53ead06f1bd25ad67767277f7f31cda93a4178a9e29129e5bc267da925ece6
|
|
4
|
+
data.tar.gz: 27a85c6db8f140b334c78c50bbc39809b524d99be02dc3513717dfa1787fd2b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
51
|
-
#
|
|
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
|
-
@
|
|
54
|
-
|
|
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
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
|
|
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-
|
|
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:
|