toggly 0.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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +34 -0
- data/lib/toggly/client.rb +287 -0
- data/lib/toggly/config.rb +139 -0
- data/lib/toggly/context.rb +149 -0
- data/lib/toggly/definitions_provider.rb +288 -0
- data/lib/toggly/errors.rb +56 -0
- data/lib/toggly/evaluation_engine.rb +136 -0
- data/lib/toggly/evaluators/always_off.rb +22 -0
- data/lib/toggly/evaluators/always_on.rb +22 -0
- data/lib/toggly/evaluators/base.rb +55 -0
- data/lib/toggly/evaluators/contextual_targeting.rb +116 -0
- data/lib/toggly/evaluators/percentage.rb +72 -0
- data/lib/toggly/evaluators/targeting.rb +51 -0
- data/lib/toggly/evaluators/time_window.rb +53 -0
- data/lib/toggly/feature_definition.rb +188 -0
- data/lib/toggly/registry.rb +86 -0
- data/lib/toggly/snapshot_providers/base.rb +67 -0
- data/lib/toggly/snapshot_providers/file.rb +95 -0
- data/lib/toggly/snapshot_providers/memory.rb +59 -0
- data/lib/toggly/version.rb +5 -0
- data/lib/toggly.rb +94 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e803470a2b9ba974d853c9b93a9da9404467f701c76ed6fbb0bd678627da7112
|
|
4
|
+
data.tar.gz: 86c792820f5ddecf1c951118bafa9d8df632299e919d8c48165e2c80e13b4b9b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fae2c7e8f07c7e88050e459ebfe2762895ae4fcebb73aced9f03985a5a12c5f01e73cecfd1943352610ac08a50eb8e8d57553203f59c99a7fcf781a80fe01a92
|
|
7
|
+
data.tar.gz: 7bbe9e747d3aa3dcde62701bb26ccd3541184b68274e8da59d49fed6d515db3c6e3e9e63e964e28bed254c35d7e0ddefa130fc865a8886843a56e4b65b5b53c9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2024-XX-XX
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of Toggly Ruby SDK
|
|
13
|
+
- `toggly` - Core SDK with zero dependencies
|
|
14
|
+
- Client for feature flag evaluation
|
|
15
|
+
- Context for user identity, groups, and traits
|
|
16
|
+
- Evaluation engine with multiple rule types
|
|
17
|
+
- Percentage rollouts with consistent hashing
|
|
18
|
+
- User and group targeting
|
|
19
|
+
- Contextual targeting with operators
|
|
20
|
+
- Time window rules
|
|
21
|
+
- Memory and file snapshot providers
|
|
22
|
+
- Background refresh support
|
|
23
|
+
- Offline mode with defaults
|
|
24
|
+
|
|
25
|
+
- `toggly-rails` - Rails integration
|
|
26
|
+
- Railtie for auto-configuration
|
|
27
|
+
- Controller concern with `feature_enabled?` helper
|
|
28
|
+
- View helpers (`when_feature_enabled`, `feature_switch`)
|
|
29
|
+
- Rack middleware for request context
|
|
30
|
+
- Context builder from current_user
|
|
31
|
+
- Rails.cache snapshot provider
|
|
32
|
+
- Generator for initializer
|
|
33
|
+
- Rake tasks (list, check, refresh, config)
|
|
34
|
+
- RSpec and Minitest helpers
|
|
35
|
+
|
|
36
|
+
- `toggly-cache` - Redis caching support
|
|
37
|
+
- Redis snapshot provider
|
|
38
|
+
- Connection pool support
|
|
39
|
+
- TTL configuration
|
|
40
|
+
- Touch/extend TTL support
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Ops.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# toggly
|
|
2
|
+
|
|
3
|
+
Core Ruby SDK for [Toggly](https://toggly.io) feature flag management.
|
|
4
|
+
|
|
5
|
+
**Zero dependencies** - pure Ruby implementation.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'toggly'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require 'toggly'
|
|
17
|
+
|
|
18
|
+
client = Toggly::Client.new(
|
|
19
|
+
app_key: 'your-app-key',
|
|
20
|
+
environment: 'Production'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if client.enabled?(:my_feature)
|
|
24
|
+
# Feature is enabled
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Documentation
|
|
29
|
+
|
|
30
|
+
See the [main README](../README.md) for full documentation.
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Main client for interacting with Toggly feature flags.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# client = Toggly::Client.new(
|
|
8
|
+
# app_key: "your-app-key",
|
|
9
|
+
# environment: "Production"
|
|
10
|
+
# )
|
|
11
|
+
#
|
|
12
|
+
# if client.enabled?("my-feature")
|
|
13
|
+
# # Feature is enabled
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example With configuration object
|
|
17
|
+
# config = Toggly::Config.new(
|
|
18
|
+
# app_key: "your-app-key",
|
|
19
|
+
# environment: "Production",
|
|
20
|
+
# refresh_interval: 60
|
|
21
|
+
# )
|
|
22
|
+
# client = Toggly::Client.new(config)
|
|
23
|
+
class Client
|
|
24
|
+
# @return [Config] Client configuration
|
|
25
|
+
attr_reader :config
|
|
26
|
+
|
|
27
|
+
# @return [Hash<String, FeatureDefinition>] Current definitions
|
|
28
|
+
attr_reader :definitions
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] Whether the client is ready
|
|
31
|
+
attr_reader :ready
|
|
32
|
+
|
|
33
|
+
# Create a new client
|
|
34
|
+
#
|
|
35
|
+
# @param config_or_options [Config, Hash] Configuration object or options hash
|
|
36
|
+
def initialize(config_or_options = {})
|
|
37
|
+
@config = config_or_options.is_a?(Config) ? config_or_options : Config.new(**config_or_options)
|
|
38
|
+
@config.validate!
|
|
39
|
+
|
|
40
|
+
@definitions = {}
|
|
41
|
+
@mutex = Mutex.new
|
|
42
|
+
@ready = false
|
|
43
|
+
@closed = false
|
|
44
|
+
|
|
45
|
+
@engine = EvaluationEngine.new(logger: @config.logger)
|
|
46
|
+
@provider = DefinitionsProvider.new(
|
|
47
|
+
config: @config,
|
|
48
|
+
logger: @config.logger,
|
|
49
|
+
on_definitions_updated: -> { refresh }
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@refresh_thread = nil
|
|
53
|
+
|
|
54
|
+
initialize_definitions
|
|
55
|
+
start_background_refresh unless @config.disable_background_refresh
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if a feature is enabled
|
|
59
|
+
#
|
|
60
|
+
# @param feature_key [String, Symbol] The feature key
|
|
61
|
+
# @param context [Context, nil] Optional evaluation context
|
|
62
|
+
# @param default [Boolean] Default value if feature not found
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def enabled?(feature_key, context: nil, default: nil)
|
|
65
|
+
key = feature_key.to_s
|
|
66
|
+
|
|
67
|
+
definition = @mutex.synchronize { @definitions[key] }
|
|
68
|
+
|
|
69
|
+
# Check defaults if not found
|
|
70
|
+
if definition.nil?
|
|
71
|
+
return default unless default.nil?
|
|
72
|
+
return @config.defaults[key] if @config.defaults.key?(key)
|
|
73
|
+
return @config.enable_undefined_in_dev if development?
|
|
74
|
+
|
|
75
|
+
return false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@engine.evaluate(definition, context)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if a feature is disabled
|
|
82
|
+
#
|
|
83
|
+
# @param feature_key [String, Symbol] The feature key
|
|
84
|
+
# @param context [Context, nil] Optional evaluation context
|
|
85
|
+
# @param default [Boolean] Default value if feature not found
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def disabled?(feature_key, context: nil, default: nil)
|
|
88
|
+
!enabled?(feature_key, context: context, default: default.nil? ? nil : !default)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get detailed evaluation result
|
|
92
|
+
#
|
|
93
|
+
# @param feature_key [String, Symbol] The feature key
|
|
94
|
+
# @param context [Context, nil] Optional evaluation context
|
|
95
|
+
# @return [EvaluationResult]
|
|
96
|
+
def evaluate(feature_key, context: nil)
|
|
97
|
+
key = feature_key.to_s
|
|
98
|
+
definition = @mutex.synchronize { @definitions[key] }
|
|
99
|
+
|
|
100
|
+
@engine.evaluate_with_details(definition, context)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get a feature definition
|
|
104
|
+
#
|
|
105
|
+
# @param feature_key [String, Symbol] The feature key
|
|
106
|
+
# @return [FeatureDefinition, nil]
|
|
107
|
+
def feature(feature_key)
|
|
108
|
+
@mutex.synchronize { @definitions[feature_key.to_s] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get all feature keys
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<String>]
|
|
114
|
+
def feature_keys
|
|
115
|
+
@mutex.synchronize { @definitions.keys }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get all features
|
|
119
|
+
#
|
|
120
|
+
# @return [Array<FeatureDefinition>]
|
|
121
|
+
def features
|
|
122
|
+
@mutex.synchronize { @definitions.values }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Manually refresh definitions
|
|
126
|
+
#
|
|
127
|
+
# @param force [Boolean] Force refresh even if not modified
|
|
128
|
+
# @return [Boolean] Whether definitions were updated
|
|
129
|
+
def refresh(force: false)
|
|
130
|
+
return false if @config.offline_mode?
|
|
131
|
+
|
|
132
|
+
new_definitions = @provider.fetch(force: force)
|
|
133
|
+
|
|
134
|
+
if new_definitions
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
@definitions = new_definitions
|
|
137
|
+
@ready = true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
save_snapshot
|
|
141
|
+
log_info("Definitions refreshed (#{new_definitions.size} features)")
|
|
142
|
+
true
|
|
143
|
+
else
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
log_error("Failed to refresh definitions: #{e.message}")
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Close the client and stop background refresh
|
|
152
|
+
def close
|
|
153
|
+
@closed = true
|
|
154
|
+
@provider.stop_websocket
|
|
155
|
+
@refresh_thread&.kill
|
|
156
|
+
@refresh_thread = nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Check if client is closed
|
|
160
|
+
#
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def closed?
|
|
163
|
+
@closed
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Wait for client to be ready
|
|
167
|
+
#
|
|
168
|
+
# @param timeout [Numeric] Maximum wait time in seconds
|
|
169
|
+
# @return [Boolean] Whether client became ready
|
|
170
|
+
def wait_for_ready(timeout: 5)
|
|
171
|
+
start_time = Time.now
|
|
172
|
+
|
|
173
|
+
until @ready
|
|
174
|
+
return false if (Time.now - start_time) > timeout
|
|
175
|
+
|
|
176
|
+
sleep(0.01)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def initialize_definitions
|
|
185
|
+
# Try to load from snapshot first
|
|
186
|
+
load_snapshot if @config.snapshot_provider
|
|
187
|
+
|
|
188
|
+
# Initialize with defaults if in offline mode
|
|
189
|
+
if @config.offline_mode?
|
|
190
|
+
@config.defaults.each do |key, value|
|
|
191
|
+
@definitions[key] = FeatureDefinition.new(
|
|
192
|
+
feature_key: key,
|
|
193
|
+
enabled: value
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
@ready = true
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Fetch from API
|
|
201
|
+
refresh(force: true)
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
log_error("Failed to initialize definitions: #{e.message}")
|
|
204
|
+
|
|
205
|
+
# Use snapshot or defaults as fallback
|
|
206
|
+
@ready = true if @definitions.any? || @config.defaults.any?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def start_background_refresh
|
|
210
|
+
return if @config.disable_background_refresh
|
|
211
|
+
return if @config.offline_mode?
|
|
212
|
+
return if @config.refresh_interval <= 0
|
|
213
|
+
|
|
214
|
+
@refresh_thread = Thread.new do
|
|
215
|
+
loop do
|
|
216
|
+
break if @closed
|
|
217
|
+
|
|
218
|
+
sleep(@config.refresh_interval)
|
|
219
|
+
break if @closed
|
|
220
|
+
|
|
221
|
+
# When WebSocket is connected, skip HTTP refresh unless
|
|
222
|
+
# the fallback interval has elapsed
|
|
223
|
+
next if @provider.should_skip_refresh?
|
|
224
|
+
|
|
225
|
+
refresh
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
@refresh_thread.abort_on_exception = false
|
|
230
|
+
|
|
231
|
+
# Start WebSocket live updates after background refresh is set up
|
|
232
|
+
start_live_updates
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def start_live_updates
|
|
236
|
+
return unless @config.enable_live_updates
|
|
237
|
+
return if @config.offline_mode?
|
|
238
|
+
|
|
239
|
+
@provider.start_websocket
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def load_snapshot
|
|
243
|
+
return unless @config.snapshot_provider
|
|
244
|
+
|
|
245
|
+
data = @config.snapshot_provider.load
|
|
246
|
+
return unless data
|
|
247
|
+
|
|
248
|
+
@mutex.synchronize do
|
|
249
|
+
@definitions = data[:definitions]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
log_debug("Loaded #{@definitions.size} features from snapshot")
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
log_warn("Failed to load snapshot: #{e.message}")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def save_snapshot
|
|
258
|
+
return unless @config.snapshot_provider
|
|
259
|
+
|
|
260
|
+
@config.snapshot_provider.save(@definitions)
|
|
261
|
+
log_debug("Saved snapshot with #{@definitions.size} features")
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
log_warn("Failed to save snapshot: #{e.message}")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def development?
|
|
267
|
+
env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || ENV["APP_ENV"] || "development"
|
|
268
|
+
env.downcase == "development"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def log_info(message)
|
|
272
|
+
@config.logger&.info("[Toggly] #{message}")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def log_debug(message)
|
|
276
|
+
@config.logger&.debug("[Toggly] #{message}")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def log_warn(message)
|
|
280
|
+
@config.logger&.warn("[Toggly] #{message}")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def log_error(message)
|
|
284
|
+
@config.logger&.error("[Toggly] #{message}")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Configuration for the Toggly client.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# config = Toggly::Config.new(
|
|
8
|
+
# app_key: "your-app-key",
|
|
9
|
+
# environment: "Production"
|
|
10
|
+
# )
|
|
11
|
+
class Config
|
|
12
|
+
# @return [String] Application key from Toggly dashboard
|
|
13
|
+
attr_accessor :app_key
|
|
14
|
+
|
|
15
|
+
# @return [String] Environment name (e.g., "Production", "Staging")
|
|
16
|
+
attr_accessor :environment
|
|
17
|
+
|
|
18
|
+
# @return [String] Base URL for Toggly API
|
|
19
|
+
attr_accessor :base_url
|
|
20
|
+
|
|
21
|
+
# @return [String] Definitions URL (overrides base_url for definitions)
|
|
22
|
+
attr_accessor :definitions_url
|
|
23
|
+
|
|
24
|
+
# @return [Integer] Refresh interval in seconds
|
|
25
|
+
attr_accessor :refresh_interval
|
|
26
|
+
|
|
27
|
+
# @return [Integer] HTTP timeout in seconds
|
|
28
|
+
attr_accessor :http_timeout
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] Enable undefined features in development
|
|
31
|
+
attr_accessor :enable_undefined_in_dev
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] Disable background refresh
|
|
34
|
+
attr_accessor :disable_background_refresh
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] Enable WebSocket live updates (default: true)
|
|
37
|
+
attr_accessor :enable_live_updates
|
|
38
|
+
|
|
39
|
+
# @return [String] Application version
|
|
40
|
+
attr_accessor :app_version
|
|
41
|
+
|
|
42
|
+
# @return [String] Instance name for distributed systems
|
|
43
|
+
attr_accessor :instance_name
|
|
44
|
+
|
|
45
|
+
# @return [Hash<String, Boolean>] Default feature values for offline mode
|
|
46
|
+
attr_accessor :defaults
|
|
47
|
+
|
|
48
|
+
# @return [SnapshotProviders::Base, nil] Snapshot provider for persistence
|
|
49
|
+
attr_accessor :snapshot_provider
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] Use signed definitions
|
|
52
|
+
attr_accessor :use_signed_definitions
|
|
53
|
+
|
|
54
|
+
# @return [Array<String>] Allowed key IDs for signed definitions
|
|
55
|
+
attr_accessor :allowed_key_ids
|
|
56
|
+
|
|
57
|
+
# @return [Logger, nil] Logger instance
|
|
58
|
+
attr_accessor :logger
|
|
59
|
+
|
|
60
|
+
# Default values
|
|
61
|
+
DEFAULT_BASE_URL = "https://definitions.toggly.io/"
|
|
62
|
+
DEFAULT_REFRESH_INTERVAL = 300 # 5 minutes
|
|
63
|
+
DEFAULT_HTTP_TIMEOUT = 10 # seconds
|
|
64
|
+
DEFAULT_ENVIRONMENT = "Production"
|
|
65
|
+
|
|
66
|
+
def initialize(**options)
|
|
67
|
+
@app_key = options[:app_key]
|
|
68
|
+
@environment = options[:environment] || DEFAULT_ENVIRONMENT
|
|
69
|
+
@base_url = normalize_url(options[:base_url] || DEFAULT_BASE_URL)
|
|
70
|
+
@definitions_url = options[:definitions_url]
|
|
71
|
+
@refresh_interval = options[:refresh_interval] || DEFAULT_REFRESH_INTERVAL
|
|
72
|
+
@http_timeout = options[:http_timeout] || DEFAULT_HTTP_TIMEOUT
|
|
73
|
+
@enable_undefined_in_dev = options[:enable_undefined_in_dev] || false
|
|
74
|
+
@disable_background_refresh = options[:disable_background_refresh] || false
|
|
75
|
+
@enable_live_updates = options.fetch(:enable_live_updates, true)
|
|
76
|
+
@app_version = options[:app_version]
|
|
77
|
+
@instance_name = options[:instance_name]
|
|
78
|
+
@defaults = options[:defaults] || {}
|
|
79
|
+
@snapshot_provider = options[:snapshot_provider]
|
|
80
|
+
@use_signed_definitions = options[:use_signed_definitions] || false
|
|
81
|
+
@allowed_key_ids = options[:allowed_key_ids] || []
|
|
82
|
+
@logger = options[:logger]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get the definitions endpoint URL
|
|
86
|
+
#
|
|
87
|
+
# @return [String]
|
|
88
|
+
def definitions_endpoint
|
|
89
|
+
base = @definitions_url || @base_url
|
|
90
|
+
endpoint = @use_signed_definitions ? "definitions-signed" : "definitions"
|
|
91
|
+
"#{normalize_url(base)}#{endpoint}/#{@app_key}/#{@environment}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Validate the configuration
|
|
95
|
+
#
|
|
96
|
+
# @raise [ConfigError] if configuration is invalid
|
|
97
|
+
def validate!
|
|
98
|
+
return if offline_mode?
|
|
99
|
+
|
|
100
|
+
raise ConfigError, "app_key is required" if @app_key.nil? || @app_key.empty?
|
|
101
|
+
raise ConfigError, "environment is required" if @environment.nil? || @environment.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if running in offline mode (defaults only)
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def offline_mode?
|
|
108
|
+
(@app_key.nil? || @app_key.empty?) && !@defaults.empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Convert to hash
|
|
112
|
+
#
|
|
113
|
+
# @return [Hash]
|
|
114
|
+
def to_h
|
|
115
|
+
{
|
|
116
|
+
app_key: @app_key,
|
|
117
|
+
environment: @environment,
|
|
118
|
+
base_url: @base_url,
|
|
119
|
+
definitions_url: @definitions_url,
|
|
120
|
+
refresh_interval: @refresh_interval,
|
|
121
|
+
http_timeout: @http_timeout,
|
|
122
|
+
enable_undefined_in_dev: @enable_undefined_in_dev,
|
|
123
|
+
disable_background_refresh: @disable_background_refresh,
|
|
124
|
+
enable_live_updates: @enable_live_updates,
|
|
125
|
+
app_version: @app_version,
|
|
126
|
+
instance_name: @instance_name,
|
|
127
|
+
use_signed_definitions: @use_signed_definitions
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def normalize_url(url)
|
|
134
|
+
return url if url.nil?
|
|
135
|
+
|
|
136
|
+
url.end_with?("/") ? url : "#{url}/"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Evaluation context containing user identity, groups, and traits.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# context = Toggly::Context.new(
|
|
8
|
+
# identity: "user-123",
|
|
9
|
+
# groups: ["beta-testers", "premium"],
|
|
10
|
+
# traits: { country: "US", plan: "enterprise" }
|
|
11
|
+
# )
|
|
12
|
+
class Context
|
|
13
|
+
# @return [String, nil] User identity for percentage rollouts and targeting
|
|
14
|
+
attr_reader :identity
|
|
15
|
+
|
|
16
|
+
# @return [Array<String>] Groups the user belongs to
|
|
17
|
+
attr_reader :groups
|
|
18
|
+
|
|
19
|
+
# @return [Hash<String, Object>] Custom traits for contextual targeting
|
|
20
|
+
attr_reader :traits
|
|
21
|
+
|
|
22
|
+
def initialize(identity: nil, groups: [], traits: {})
|
|
23
|
+
@identity = identity&.to_s
|
|
24
|
+
@groups = Array(groups).map(&:to_s)
|
|
25
|
+
@traits = normalize_traits(traits)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Create a context with just an identity
|
|
29
|
+
#
|
|
30
|
+
# @param identity [String] User identity
|
|
31
|
+
# @return [Context]
|
|
32
|
+
def self.with_identity(identity)
|
|
33
|
+
new(identity: identity)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Create an empty/anonymous context
|
|
37
|
+
#
|
|
38
|
+
# @return [Context]
|
|
39
|
+
def self.anonymous
|
|
40
|
+
new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if context has an identity
|
|
44
|
+
#
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def identity?
|
|
47
|
+
!@identity.nil? && !@identity.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if user is in a specific group
|
|
51
|
+
#
|
|
52
|
+
# @param group [String, Symbol] Group name
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def in_group?(group)
|
|
55
|
+
@groups.include?(group.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get a trait value
|
|
59
|
+
#
|
|
60
|
+
# @param key [String, Symbol] Trait key
|
|
61
|
+
# @return [Object, nil]
|
|
62
|
+
def trait(key)
|
|
63
|
+
@traits[key.to_s]
|
|
64
|
+
end
|
|
65
|
+
alias [] trait
|
|
66
|
+
|
|
67
|
+
# Check if a trait exists
|
|
68
|
+
#
|
|
69
|
+
# @param key [String, Symbol] Trait key
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def trait?(key)
|
|
72
|
+
@traits.key?(key.to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Create a new context with additional traits
|
|
76
|
+
#
|
|
77
|
+
# @param new_traits [Hash] Traits to add
|
|
78
|
+
# @return [Context]
|
|
79
|
+
def with_traits(new_traits)
|
|
80
|
+
Context.new(
|
|
81
|
+
identity: @identity,
|
|
82
|
+
groups: @groups,
|
|
83
|
+
traits: @traits.merge(normalize_traits(new_traits))
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Create a new context with additional groups
|
|
88
|
+
#
|
|
89
|
+
# @param new_groups [Array<String>] Groups to add
|
|
90
|
+
# @return [Context]
|
|
91
|
+
def with_groups(*new_groups)
|
|
92
|
+
Context.new(
|
|
93
|
+
identity: @identity,
|
|
94
|
+
groups: @groups + new_groups.flatten.map(&:to_s),
|
|
95
|
+
traits: @traits
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Convert to hash for serialization
|
|
100
|
+
#
|
|
101
|
+
# @return [Hash]
|
|
102
|
+
def to_h
|
|
103
|
+
{
|
|
104
|
+
identity: @identity,
|
|
105
|
+
groups: @groups,
|
|
106
|
+
traits: @traits
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check equality
|
|
111
|
+
#
|
|
112
|
+
# @param other [Context]
|
|
113
|
+
# @return [Boolean]
|
|
114
|
+
def ==(other)
|
|
115
|
+
return false unless other.is_a?(Context)
|
|
116
|
+
|
|
117
|
+
@identity == other.identity &&
|
|
118
|
+
@groups.sort == other.groups.sort &&
|
|
119
|
+
@traits == other.traits
|
|
120
|
+
end
|
|
121
|
+
alias eql? ==
|
|
122
|
+
|
|
123
|
+
# Hash code for use in collections
|
|
124
|
+
#
|
|
125
|
+
# @return [Integer]
|
|
126
|
+
def hash
|
|
127
|
+
[@identity, @groups.sort, @traits].hash
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Generate cache key
|
|
131
|
+
#
|
|
132
|
+
# @return [String]
|
|
133
|
+
def cache_key
|
|
134
|
+
"#{@identity}:#{@groups.sort.join(",")}:#{traits_cache_key}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def normalize_traits(traits)
|
|
140
|
+
return {} unless traits.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
traits.transform_keys(&:to_s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def traits_cache_key
|
|
146
|
+
@traits.sort.map { |k, v| "#{k}=#{v}" }.join(",")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|