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 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