toggly-rails 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: c6508b1078a5f1691f9ceaf7be6b3c51ff247f347ebbf7b109eacd683216fe7b
4
+ data.tar.gz: c2befaafb3b4605966294e8e9fff0e3499ac245edeb2ad9b5c457cefddcc3a8b
5
+ SHA512:
6
+ metadata.gz: 8343572f95ce19a45bdb6127b64cbe494940cba780e98f80fc211dec31ea09750261d6dc60e43ea3ff5284d5014bc2e3f8731218d20b47bf69824d89d92c488d
7
+ data.tar.gz: 61a4937f82a36dd6db037337f7d469e208295d4cfc07e99d739dad0b573a7ef319e80ab79919fe7ad272c91a5b72b5b5447b8cb6f749249fc26ebdf3ec4a6f30
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,54 @@
1
+ # toggly-rails
2
+
3
+ Rails integration for [Toggly](https://toggly.io) feature flag management.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem 'toggly-rails'
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ rails generate toggly:install
15
+ ```
16
+
17
+ Then configure in `config/initializers/toggly.rb`:
18
+
19
+ ```ruby
20
+ Toggly::Rails.configure do |config|
21
+ config.app_key = ENV['TOGGLY_APP_KEY']
22
+ config.environment = Rails.env.production? ? 'Production' : 'Staging'
23
+ end
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Controllers
29
+
30
+ ```ruby
31
+ class DashboardController < ApplicationController
32
+ def show
33
+ if feature_enabled?(:new_dashboard)
34
+ render :new_dashboard
35
+ end
36
+ end
37
+ end
38
+ ```
39
+
40
+ ### Views
41
+
42
+ ```erb
43
+ <% if feature_enabled?(:promo) %>
44
+ <div class="promo">Special offer!</div>
45
+ <% end %>
46
+ ```
47
+
48
+ ## Documentation
49
+
50
+ See the [main README](../README.md) for full documentation.
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # Snapshot provider that uses Rails.cache.
6
+ #
7
+ # Stores feature definitions in the Rails cache store
8
+ # for persistence and sharing across processes.
9
+ class CacheSnapshotProvider < Toggly::SnapshotProviders::Base
10
+ # @param key_prefix [String] Cache key prefix
11
+ # @param expires_in [Integer, nil] Cache expiration in seconds (nil = no expiration)
12
+ def initialize(key_prefix: "toggly", expires_in: nil)
13
+ super()
14
+ @key_prefix = key_prefix
15
+ @expires_in = expires_in
16
+ end
17
+
18
+ # Save definitions to Rails cache
19
+ #
20
+ # @param definitions [Hash<String, FeatureDefinition>] Definitions
21
+ # @param metadata [Hash] Optional metadata
22
+ def save(definitions, metadata = {})
23
+ data = {
24
+ definitions: serialize_definitions(definitions),
25
+ metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
26
+ }
27
+
28
+ cache_options = {}
29
+ cache_options[:expires_in] = @expires_in if @expires_in
30
+
31
+ ::Rails.cache.write(cache_key, data, **cache_options)
32
+ end
33
+
34
+ # Load definitions from Rails cache
35
+ #
36
+ # @return [Hash, nil] Hash with :definitions and :metadata
37
+ def load
38
+ data = ::Rails.cache.read(cache_key)
39
+ return nil unless data
40
+
41
+ {
42
+ definitions: deserialize_definitions(data[:definitions]),
43
+ metadata: data[:metadata] || {}
44
+ }
45
+ end
46
+
47
+ # Clear the cached snapshot
48
+ def clear
49
+ ::Rails.cache.delete(cache_key)
50
+ end
51
+
52
+ # Check if snapshot exists in cache
53
+ #
54
+ # @return [Boolean]
55
+ def exists?
56
+ ::Rails.cache.exist?(cache_key)
57
+ end
58
+
59
+ private
60
+
61
+ def cache_key
62
+ "#{@key_prefix}:snapshot"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # Rails-specific configuration for Toggly.
6
+ #
7
+ # Extends the base Toggly configuration with Rails-specific options
8
+ # like context builders and middleware settings.
9
+ class Configuration
10
+ # Core configuration options (delegated to Toggly::Config)
11
+ attr_accessor :app_key, :environment, :base_url, :definitions_url,
12
+ :refresh_interval, :http_timeout,
13
+ :enable_undefined_in_dev, :disable_background_refresh,
14
+ :app_version, :instance_name, :defaults,
15
+ :snapshot_provider, :use_signed_definitions, :allowed_key_ids
16
+
17
+ # Rails-specific options
18
+
19
+ # @return [Proc, nil] Custom context builder proc
20
+ attr_accessor :context_builder
21
+
22
+ # @return [Boolean] Enable request-scoped context (via middleware)
23
+ attr_accessor :request_context_enabled
24
+
25
+ # @return [Symbol] Method to call on current_user for identity
26
+ attr_accessor :identity_method
27
+
28
+ # @return [Symbol] Method to call on current_user for groups
29
+ attr_accessor :groups_method
30
+
31
+ # @return [Hash<Symbol, Proc>] Custom trait extractors
32
+ attr_accessor :trait_extractors
33
+
34
+ # @return [Boolean] Use Rails.cache as snapshot provider
35
+ attr_accessor :use_rails_cache
36
+
37
+ # @return [String] Cache key prefix for Rails.cache
38
+ attr_accessor :cache_key_prefix
39
+
40
+ def initialize
41
+ # Core defaults
42
+ @environment = ::Rails.env.production? ? "Production" : "Staging" if defined?(::Rails)
43
+ @refresh_interval = 300
44
+ @http_timeout = 10
45
+ @enable_undefined_in_dev = false
46
+ @disable_background_refresh = false
47
+ @defaults = {}
48
+ @use_signed_definitions = false
49
+ @allowed_key_ids = []
50
+
51
+ # Rails-specific defaults
52
+ @request_context_enabled = true
53
+ @identity_method = :id
54
+ @groups_method = nil
55
+ @trait_extractors = {}
56
+ @use_rails_cache = false
57
+ @cache_key_prefix = "toggly"
58
+ end
59
+
60
+ # Apply this configuration to a Toggly::Config object
61
+ #
62
+ # @param config [Toggly::Config]
63
+ # @return [void]
64
+ def apply_to(config)
65
+ config.app_key = app_key
66
+ config.environment = environment
67
+ config.base_url = base_url if base_url
68
+ config.definitions_url = definitions_url if definitions_url
69
+ config.refresh_interval = refresh_interval
70
+ config.http_timeout = http_timeout
71
+ config.enable_undefined_in_dev = enable_undefined_in_dev
72
+ config.disable_background_refresh = disable_background_refresh
73
+ config.app_version = app_version if app_version
74
+ config.instance_name = instance_name if instance_name
75
+ config.defaults = defaults
76
+ config.use_signed_definitions = use_signed_definitions
77
+ config.allowed_key_ids = allowed_key_ids
78
+
79
+ # Set up snapshot provider
80
+ config.snapshot_provider = build_snapshot_provider
81
+
82
+ # Use Rails logger
83
+ config.logger = ::Rails.logger if defined?(::Rails)
84
+ end
85
+
86
+ # Add a custom trait extractor
87
+ #
88
+ # @param name [Symbol] Trait name
89
+ # @yield [request, user] Block that returns the trait value
90
+ # @return [void]
91
+ def add_trait(name, &block)
92
+ @trait_extractors[name.to_sym] = block
93
+ end
94
+
95
+ private
96
+
97
+ def build_snapshot_provider
98
+ return snapshot_provider if snapshot_provider
99
+ return nil unless use_rails_cache && defined?(::Rails)
100
+
101
+ require_relative "cache_snapshot_provider"
102
+ CacheSnapshotProvider.new(key_prefix: cache_key_prefix)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # Builds evaluation context from Rails request and user.
6
+ #
7
+ # Extracts identity, groups, and traits from the current request
8
+ # and authenticated user.
9
+ class ContextBuilder
10
+ # @param config [Configuration] Rails configuration
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ # Build context from request and user
16
+ #
17
+ # @param request [ActionDispatch::Request, nil] Current request
18
+ # @param user [Object, nil] Current authenticated user
19
+ # @return [Toggly::Context]
20
+ def build(request: nil, user: nil)
21
+ identity = extract_identity(user)
22
+ groups = extract_groups(user)
23
+ traits = extract_traits(request, user)
24
+
25
+ Toggly::Context.new(
26
+ identity: identity,
27
+ groups: groups,
28
+ traits: traits
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def extract_identity(user)
35
+ return nil unless user
36
+ return unless @config.identity_method && user.respond_to?(@config.identity_method)
37
+
38
+ user.public_send(@config.identity_method)&.to_s
39
+ end
40
+
41
+ def extract_groups(user)
42
+ return [] unless user && @config.groups_method
43
+ return [] unless user.respond_to?(@config.groups_method)
44
+
45
+ Array(user.public_send(@config.groups_method)).map(&:to_s)
46
+ end
47
+
48
+ def extract_traits(request, user)
49
+ traits = {}
50
+
51
+ # Add default request traits
52
+ if request
53
+ traits["request_ip"] = request.remote_ip if request.respond_to?(:remote_ip)
54
+ traits["user_agent"] = request.user_agent if request.respond_to?(:user_agent)
55
+ traits["locale"] = I18n.locale.to_s if defined?(I18n)
56
+ end
57
+
58
+ # Add custom trait extractors
59
+ @config.trait_extractors.each do |name, extractor|
60
+ value = extractor.call(request, user)
61
+ traits[name.to_s] = value unless value.nil?
62
+ rescue StandardError
63
+ # Ignore errors in trait extraction
64
+ end
65
+
66
+ traits
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # Controller concern for feature flag checks.
6
+ #
7
+ # Include this module in your ApplicationController to add
8
+ # feature flag helpers.
9
+ #
10
+ # @example
11
+ # class ApplicationController < ActionController::Base
12
+ # include Toggly::Rails::ControllerConcern
13
+ #
14
+ # # Optional: customize how the current user is identified
15
+ # def toggly_current_user
16
+ # current_user
17
+ # end
18
+ # end
19
+ module ControllerConcern
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ helper_method :feature_enabled?, :feature_disabled? if respond_to?(:helper_method)
24
+
25
+ # Set up user in middleware on each request
26
+ before_action :set_toggly_context, if: -> { Toggly::Rails.configuration&.request_context_enabled }
27
+ end
28
+
29
+ # Check if a feature is enabled
30
+ #
31
+ # @param feature_key [String, Symbol] Feature key
32
+ # @param context [Toggly::Context, nil] Optional override context
33
+ # @return [Boolean]
34
+ def feature_enabled?(feature_key, context: nil)
35
+ ctx = context || toggly_context
36
+ Toggly.enabled?(feature_key, context: ctx)
37
+ end
38
+
39
+ # Check if a feature is disabled
40
+ #
41
+ # @param feature_key [String, Symbol] Feature key
42
+ # @param context [Toggly::Context, nil] Optional override context
43
+ # @return [Boolean]
44
+ def feature_disabled?(feature_key, context: nil)
45
+ !feature_enabled?(feature_key, context: context)
46
+ end
47
+
48
+ # Require a feature to be enabled, otherwise render not found
49
+ #
50
+ # @param feature_key [String, Symbol] Feature key
51
+ # @param context [Toggly::Context, nil] Optional override context
52
+ # @return [void]
53
+ def require_feature!(feature_key, context: nil)
54
+ return if feature_enabled?(feature_key, context: context)
55
+
56
+ respond_to do |format|
57
+ format.html { render file: "public/404.html", status: :not_found, layout: false }
58
+ format.json { render json: { error: "Not found" }, status: :not_found }
59
+ format.any { head :not_found }
60
+ end
61
+ end
62
+
63
+ # Get the current evaluation context
64
+ #
65
+ # @return [Toggly::Context]
66
+ def toggly_context
67
+ @toggly_context ||= build_toggly_context
68
+ end
69
+
70
+ # Set a custom context for this request
71
+ #
72
+ # @param context [Toggly::Context] Context to use
73
+ # @return [void]
74
+ def toggly_context=(context)
75
+ @toggly_context = context
76
+ Middleware.set_context(request.env, context) if request
77
+ end
78
+
79
+ protected
80
+
81
+ # Override this method to provide the current user
82
+ #
83
+ # @return [Object, nil]
84
+ def toggly_current_user
85
+ return current_user if respond_to?(:current_user, true)
86
+
87
+ nil
88
+ end
89
+
90
+ private
91
+
92
+ def set_toggly_context
93
+ return unless request
94
+
95
+ Middleware.set_user(request.env, toggly_current_user)
96
+ Middleware.set_context(request.env, toggly_context)
97
+ end
98
+
99
+ def build_toggly_context
100
+ config = Toggly::Rails.configuration
101
+ return Toggly::Context.anonymous unless config
102
+
103
+ # Use custom context builder if provided
104
+ return config.context_builder.call(request, toggly_current_user) if config.context_builder
105
+
106
+ # Use default context builder
107
+ builder = ContextBuilder.new(config)
108
+ builder.build(request: request, user: toggly_current_user)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Toggly
6
+ module Generators
7
+ # Generator for Toggly Rails initializer.
8
+ #
9
+ # @example
10
+ # rails generate toggly:install
11
+ class InstallGenerator < ::Rails::Generators::Base
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Creates a Toggly initializer file"
15
+
16
+ class_option :app_key, type: :string, desc: "Your Toggly app key"
17
+ class_option :environment, type: :string, desc: "Environment name"
18
+
19
+ def create_initializer_file
20
+ template "toggly.rb.erb", "config/initializers/toggly.rb"
21
+ end
22
+
23
+ def show_post_install_message
24
+ say ""
25
+ say "Toggly has been installed! 🎉", :green
26
+ say ""
27
+ say "Next steps:"
28
+ say " 1. Update config/initializers/toggly.rb with your app key"
29
+ say " 2. Or add to your credentials: rails credentials:edit"
30
+ say " toggly:"
31
+ say " app_key: your-app-key"
32
+ say " environment: Production"
33
+ say ""
34
+ say "Usage in controllers:"
35
+ say " if feature_enabled?(:my_feature)"
36
+ say " # feature is on"
37
+ say " end"
38
+ say ""
39
+ say "Usage in views:"
40
+ say " <% if feature_enabled?(:my_feature) %>"
41
+ say " Feature content"
42
+ say " <% end %>"
43
+ say ""
44
+ end
45
+
46
+ private
47
+
48
+ def app_key
49
+ options[:app_key] || "ENV.fetch('TOGGLY_APP_KEY', nil)"
50
+ end
51
+
52
+ def environment_name
53
+ options[:environment] || "Rails.env.production? ? 'Production' : 'Staging'"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Toggly Feature Flags Configuration
4
+ # https://docs.toggly.io/sdks/ruby
5
+
6
+ Toggly::Rails.configure do |config|
7
+ # Required: Your Toggly app key
8
+ # Get this from your Toggly dashboard: https://app.toggly.io
9
+ config.app_key = <%= app_key %>
10
+
11
+ # Required: Environment name
12
+ # Common values: "Production", "Staging", "Development"
13
+ config.environment = <%= environment_name %>
14
+
15
+ # Optional: Custom API endpoint (for self-hosted or enterprise)
16
+ # config.base_url = "https://custom.toggly.io"
17
+
18
+ # Optional: How often to refresh definitions (in seconds)
19
+ # config.refresh_interval = 300
20
+
21
+ # Optional: HTTP timeout for API requests (in seconds)
22
+ # config.http_timeout = 10
23
+
24
+ # Optional: Enable features by default in development
25
+ # config.enable_undefined_in_dev = Rails.env.development?
26
+
27
+ # Optional: Disable background refresh (useful in tests)
28
+ # config.disable_background_refresh = Rails.env.test?
29
+
30
+ # Optional: Use Rails.cache for persistence
31
+ # config.use_rails_cache = true
32
+
33
+ # Optional: Custom context builder
34
+ # config.context_builder = ->(request, user) do
35
+ # Toggly::Context.new(
36
+ # identity: user&.id&.to_s,
37
+ # groups: user&.roles&.pluck(:name) || [],
38
+ # traits: {
39
+ # plan: user&.subscription&.plan,
40
+ # country: request&.headers["CF-IPCountry"]
41
+ # }
42
+ # )
43
+ # end
44
+
45
+ # Optional: Configure how user identity is extracted
46
+ # config.identity_method = :id # Method to call on current_user
47
+
48
+ # Optional: Configure how user groups are extracted
49
+ # config.groups_method = :role_names # Method to call on current_user
50
+
51
+ # Optional: Add custom trait extractors
52
+ # config.add_trait(:plan) { |request, user| user&.subscription&.plan }
53
+ # config.add_trait(:country) { |request, _user| request&.headers["CF-IPCountry"] }
54
+
55
+ # Optional: Default values for offline mode
56
+ # config.defaults = {
57
+ # "critical-feature" => true,
58
+ # "experimental-feature" => false
59
+ # }
60
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # Rack middleware for request-scoped Toggly context.
6
+ #
7
+ # Stores the evaluation context in the request environment
8
+ # for access throughout the request lifecycle.
9
+ class Middleware
10
+ # Environment key for storing the context
11
+ CONTEXT_KEY = "toggly.context"
12
+ USER_KEY = "toggly.current_user"
13
+
14
+ def initialize(app)
15
+ @app = app
16
+ end
17
+
18
+ def call(env)
19
+ # Clear any existing context at the start of the request
20
+ env.delete(CONTEXT_KEY)
21
+ env.delete(USER_KEY)
22
+
23
+ @app.call(env)
24
+ ensure
25
+ # Clean up after request
26
+ env.delete(CONTEXT_KEY)
27
+ env.delete(USER_KEY)
28
+ end
29
+
30
+ class << self
31
+ # Get the current context from the request environment
32
+ #
33
+ # @param env [Hash] Rack environment
34
+ # @return [Toggly::Context, nil]
35
+ def context(env)
36
+ env[CONTEXT_KEY]
37
+ end
38
+
39
+ # Set the current context in the request environment
40
+ #
41
+ # @param env [Hash] Rack environment
42
+ # @param context [Toggly::Context] Context to set
43
+ # @return [Toggly::Context]
44
+ def set_context(env, context)
45
+ env[CONTEXT_KEY] = context
46
+ end
47
+
48
+ # Set the current user for context building
49
+ #
50
+ # @param env [Hash] Rack environment
51
+ # @param user [Object] Current user
52
+ # @return [Object]
53
+ def set_user(env, user)
54
+ env[USER_KEY] = user
55
+ end
56
+
57
+ # Get the current user from the request environment
58
+ #
59
+ # @param env [Hash] Rack environment
60
+ # @return [Object, nil]
61
+ def user(env)
62
+ env[USER_KEY]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # Rails Railtie for automatic integration.
6
+ #
7
+ # Automatically configures:
8
+ # - Middleware for request context
9
+ # - View helpers
10
+ # - Controller helpers
11
+ class Railtie < ::Rails::Railtie
12
+ initializer "toggly.middleware" do |app|
13
+ app.middleware.use Toggly::Rails::Middleware
14
+ end
15
+
16
+ initializer "toggly.view_helpers" do
17
+ ActiveSupport.on_load(:action_view) do
18
+ include Toggly::Rails::ViewHelpers
19
+ end
20
+ end
21
+
22
+ initializer "toggly.controller_helpers" do
23
+ ActiveSupport.on_load(:action_controller) do
24
+ include Toggly::Rails::ControllerConcern
25
+ end
26
+ end
27
+
28
+ # Configure Toggly after Rails initializers have run
29
+ config.after_initialize do
30
+ next unless Toggly::Rails.configuration.nil?
31
+ next unless defined?(::Rails) && ::Rails.application
32
+
33
+ credentials = ::Rails.application.credentials
34
+ next unless credentials.respond_to?(:toggly) && credentials.toggly
35
+
36
+ Toggly::Rails.configure do |config|
37
+ config.app_key = credentials.toggly[:app_key]
38
+ config.environment = credentials.toggly[:environment] || (::Rails.env.production? ? "Production" : "Staging")
39
+ end
40
+ end
41
+
42
+ # Gracefully shutdown on Rails restart
43
+ config.after_initialize do
44
+ at_exit do
45
+ Toggly.client&.close
46
+ end
47
+ end
48
+
49
+ # Add rake tasks
50
+ rake_tasks do
51
+ load "toggly/rails/tasks.rake"
52
+ end
53
+
54
+ # Add generators
55
+ generators do
56
+ require_relative "generators/toggly/install/install_generator"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :toggly do
4
+ desc "Refresh feature definitions from Toggly API"
5
+ task refresh: :environment do
6
+ if Toggly.client
7
+ puts "Refreshing Toggly feature definitions..."
8
+ if Toggly.client.refresh(force: true)
9
+ puts "Successfully refreshed #{Toggly.client.feature_keys.count} features"
10
+ else
11
+ puts "No changes detected"
12
+ end
13
+ else
14
+ puts "Toggly is not configured. Run rails generate toggly:install first."
15
+ end
16
+ end
17
+
18
+ desc "List all feature flags"
19
+ task list: :environment do
20
+ if Toggly.client
21
+ features = Toggly.client.features
22
+ if features.empty?
23
+ puts "No features found"
24
+ else
25
+ puts "Feature Flags (#{features.count}):"
26
+ puts "-" * 60
27
+ features.sort_by(&:feature_key).each do |feature|
28
+ status = feature.enabled ? "✓ enabled" : "✗ disabled"
29
+ puts " #{feature.feature_key.ljust(40)} #{status}"
30
+ end
31
+ end
32
+ else
33
+ puts "Toggly is not configured. Run rails generate toggly:install first."
34
+ end
35
+ end
36
+
37
+ desc "Check if a specific feature is enabled"
38
+ task :check, [:feature_key] => :environment do |_t, args|
39
+ feature_key = args[:feature_key]
40
+ if feature_key.nil? || feature_key.empty?
41
+ puts "Usage: rake toggly:check[feature_key]"
42
+ exit 1
43
+ end
44
+
45
+ if Toggly.client
46
+ if Toggly.enabled?(feature_key)
47
+ puts "Feature '#{feature_key}' is ENABLED"
48
+ else
49
+ puts "Feature '#{feature_key}' is DISABLED"
50
+ end
51
+ else
52
+ puts "Toggly is not configured. Run rails generate toggly:install first."
53
+ end
54
+ end
55
+
56
+ desc "Show Toggly configuration"
57
+ task config: :environment do
58
+ if Toggly::Rails.configuration
59
+ config = Toggly::Rails.configuration
60
+ puts "Toggly Configuration:"
61
+ puts "-" * 40
62
+ puts " App Key: #{config.app_key ? "#{config.app_key[0..7]}..." : "(not set)"}"
63
+ puts " Environment: #{config.environment}"
64
+ puts " Base URL: #{config.base_url || Toggly::Config::DEFAULT_BASE_URL}"
65
+ puts " Refresh Interval: #{config.refresh_interval}s"
66
+ puts " Request Context: #{config.request_context_enabled ? "enabled" : "disabled"}"
67
+ puts " Rails Cache: #{config.use_rails_cache ? "enabled" : "disabled"}"
68
+
69
+ if Toggly.client
70
+ puts "\nClient Status:"
71
+ puts " Ready: #{Toggly.client.ready ? "yes" : "no"}"
72
+ puts " Features: #{Toggly.client.feature_keys.count}"
73
+ end
74
+ else
75
+ puts "Toggly is not configured. Run rails generate toggly:install first."
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # Testing helpers for RSpec and Minitest.
6
+ #
7
+ # @example RSpec
8
+ # RSpec.configure do |config|
9
+ # config.include Toggly::Rails::Testing::RSpecHelpers
10
+ # end
11
+ #
12
+ # describe "my feature" do
13
+ # it "shows new UI when enabled" do
14
+ # with_feature(:new_ui, enabled: true) do
15
+ # visit dashboard_path
16
+ # expect(page).to have_content("New Dashboard")
17
+ # end
18
+ # end
19
+ # end
20
+ #
21
+ # @example Minitest
22
+ # class MyTest < ActionDispatch::IntegrationTest
23
+ # include Toggly::Rails::Testing::MinitestHelpers
24
+ #
25
+ # test "shows new UI when enabled" do
26
+ # with_feature(:new_ui, enabled: true) do
27
+ # get dashboard_path
28
+ # assert_includes response.body, "New Dashboard"
29
+ # end
30
+ # end
31
+ # end
32
+ module Testing
33
+ # Stub a feature to return a specific value
34
+ #
35
+ # @param feature_key [String, Symbol] Feature key
36
+ # @param enabled [Boolean] Whether the feature should be enabled
37
+ # @yield Block during which the feature is stubbed
38
+ def with_feature(feature_key, enabled: true)
39
+ key = feature_key.to_s
40
+ original_definitions = Toggly.client&.definitions&.dup || {}
41
+
42
+ # Add or update the feature definition
43
+ Toggly.client.instance_variable_get(:@mutex).synchronize do
44
+ Toggly.client.definitions[key] = Toggly::FeatureDefinition.new(
45
+ feature_key: key,
46
+ enabled: enabled
47
+ )
48
+ end
49
+
50
+ yield
51
+ ensure
52
+ # Restore original definitions
53
+ Toggly.client&.instance_variable_get(:@mutex)&.synchronize do
54
+ Toggly.client.instance_variable_set(:@definitions, original_definitions)
55
+ end
56
+ end
57
+
58
+ # Stub multiple features at once
59
+ #
60
+ # @param features [Hash<String, Boolean>] Hash of feature keys to enabled states
61
+ # @yield Block during which the features are stubbed
62
+ def with_features(features)
63
+ original_definitions = Toggly.client&.definitions&.dup || {}
64
+
65
+ features.each do |key, enabled|
66
+ Toggly.client.instance_variable_get(:@mutex).synchronize do
67
+ Toggly.client.definitions[key.to_s] = Toggly::FeatureDefinition.new(
68
+ feature_key: key.to_s,
69
+ enabled: enabled
70
+ )
71
+ end
72
+ end
73
+
74
+ yield
75
+ ensure
76
+ Toggly.client&.instance_variable_get(:@mutex)&.synchronize do
77
+ Toggly.client.instance_variable_set(:@definitions, original_definitions)
78
+ end
79
+ end
80
+
81
+ # Enable a feature for the duration of a block
82
+ #
83
+ # @param feature_key [String, Symbol] Feature key
84
+ # @yield Block during which the feature is enabled
85
+ def enable_feature(feature_key, &block)
86
+ with_feature(feature_key, enabled: true, &block)
87
+ end
88
+
89
+ # Disable a feature for the duration of a block
90
+ #
91
+ # @param feature_key [String, Symbol] Feature key
92
+ # @yield Block during which the feature is disabled
93
+ def disable_feature(feature_key, &block)
94
+ with_feature(feature_key, enabled: false, &block)
95
+ end
96
+
97
+ # RSpec helpers
98
+ module RSpecHelpers
99
+ include Testing
100
+
101
+ # RSpec matcher for checking if a feature is enabled
102
+ #
103
+ # @example
104
+ # expect(:new_feature).to be_feature_enabled
105
+ if defined?(RSpec::Matchers)
106
+ RSpec::Matchers.define :be_feature_enabled do
107
+ match do |feature_key|
108
+ Toggly.enabled?(feature_key)
109
+ end
110
+
111
+ failure_message do |feature_key|
112
+ "expected feature '#{feature_key}' to be enabled"
113
+ end
114
+
115
+ failure_message_when_negated do |feature_key|
116
+ "expected feature '#{feature_key}' to be disabled"
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ # Minitest helpers
123
+ module MinitestHelpers
124
+ include Testing
125
+
126
+ # Assert that a feature is enabled
127
+ #
128
+ # @param feature_key [String, Symbol] Feature key
129
+ # @param msg [String, nil] Custom failure message
130
+ def assert_feature_enabled(feature_key, msg = nil)
131
+ msg ||= "Expected feature '#{feature_key}' to be enabled"
132
+ assert Toggly.enabled?(feature_key), msg
133
+ end
134
+
135
+ # Assert that a feature is disabled
136
+ #
137
+ # @param feature_key [String, Symbol] Feature key
138
+ # @param msg [String, nil] Custom failure message
139
+ def assert_feature_disabled(feature_key, msg = nil)
140
+ msg ||= "Expected feature '#{feature_key}' to be disabled"
141
+ assert Toggly.disabled?(feature_key), msg
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Rails
5
+ # View helpers for feature flags in Rails views.
6
+ #
7
+ # These helpers are automatically included in ActionView
8
+ # when using the Railtie.
9
+ module ViewHelpers
10
+ # Check if a feature is enabled
11
+ #
12
+ # @param feature_key [String, Symbol] Feature key
13
+ # @param context [Toggly::Context, nil] Optional override context
14
+ # @return [Boolean]
15
+ def feature_enabled?(feature_key, context: nil)
16
+ if controller.respond_to?(:feature_enabled?, true)
17
+ controller.send(:feature_enabled?, feature_key, context: context)
18
+ else
19
+ ctx = context || view_toggly_context
20
+ Toggly.enabled?(feature_key, context: ctx)
21
+ end
22
+ end
23
+
24
+ # Check if a feature is disabled
25
+ #
26
+ # @param feature_key [String, Symbol] Feature key
27
+ # @param context [Toggly::Context, nil] Optional override context
28
+ # @return [Boolean]
29
+ def feature_disabled?(feature_key, context: nil)
30
+ !feature_enabled?(feature_key, context: context)
31
+ end
32
+
33
+ # Render content only if feature is enabled
34
+ #
35
+ # @param feature_key [String, Symbol] Feature key
36
+ # @param context [Toggly::Context, nil] Optional override context
37
+ # @yield Content to render when enabled
38
+ # @return [String, nil]
39
+ def when_feature_enabled(feature_key, context: nil, &block)
40
+ return unless feature_enabled?(feature_key, context: context)
41
+
42
+ capture(&block) if block_given?
43
+ end
44
+
45
+ # Render content only if feature is disabled
46
+ #
47
+ # @param feature_key [String, Symbol] Feature key
48
+ # @param context [Toggly::Context, nil] Optional override context
49
+ # @yield Content to render when disabled
50
+ # @return [String, nil]
51
+ def when_feature_disabled(feature_key, context: nil, &block)
52
+ return if feature_enabled?(feature_key, context: context)
53
+
54
+ capture(&block) if block_given?
55
+ end
56
+
57
+ # Render enabled or disabled content based on feature state
58
+ #
59
+ # @param feature_key [String, Symbol] Feature key
60
+ # @param enabled [String, nil] Content when enabled
61
+ # @param disabled [String, nil] Content when disabled
62
+ # @param context [Toggly::Context, nil] Optional override context
63
+ # @return [String]
64
+ def feature_switch(feature_key, enabled: nil, disabled: nil, context: nil)
65
+ if feature_enabled?(feature_key, context: context)
66
+ enabled
67
+ else
68
+ disabled
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def view_toggly_context
75
+ # Try to get context from controller
76
+ if controller.respond_to?(:toggly_context, true)
77
+ controller.send(:toggly_context)
78
+ else
79
+ Toggly::Context.anonymous
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toggly"
4
+ require_relative "toggly/rails/version"
5
+ require_relative "toggly/rails/configuration"
6
+ require_relative "toggly/rails/context_builder"
7
+ require_relative "toggly/rails/middleware"
8
+ require_relative "toggly/rails/controller_concern"
9
+ require_relative "toggly/rails/view_helpers"
10
+ require_relative "toggly/rails/railtie" if defined?(Rails::Railtie)
11
+
12
+ module Toggly
13
+ # Rails integration for Toggly feature flags.
14
+ #
15
+ # @example Basic Rails setup (config/initializers/toggly.rb)
16
+ # Toggly::Rails.configure do |config|
17
+ # config.app_key = Rails.application.credentials.toggly_app_key
18
+ # config.environment = Rails.env.production? ? "Production" : "Staging"
19
+ # end
20
+ #
21
+ # @example Controller usage
22
+ # class ApplicationController < ActionController::Base
23
+ # include Toggly::Rails::ControllerConcern
24
+ #
25
+ # def show
26
+ # if feature_enabled?(:new_dashboard)
27
+ # render :new_dashboard
28
+ # else
29
+ # render :dashboard
30
+ # end
31
+ # end
32
+ # end
33
+ #
34
+ # @example View usage
35
+ # <% if feature_enabled?(:new_header) %>
36
+ # <%= render "new_header" %>
37
+ # <% else %>
38
+ # <%= render "header" %>
39
+ # <% end %>
40
+ module Rails
41
+ class << self
42
+ # @return [Configuration] Rails-specific configuration
43
+ attr_accessor :configuration
44
+
45
+ # Configure Toggly for Rails
46
+ #
47
+ # @yield [Configuration] Configuration block
48
+ # @return [void]
49
+ def configure
50
+ self.configuration ||= Configuration.new
51
+ yield(configuration) if block_given?
52
+
53
+ # Create the Toggly client from Rails configuration
54
+ Toggly.configure do |config|
55
+ configuration.apply_to(config)
56
+ end
57
+ end
58
+
59
+ # Reset configuration (mainly for testing)
60
+ def reset!
61
+ self.configuration = nil
62
+ Toggly.reset!
63
+ end
64
+
65
+ # Get the current Toggly client
66
+ #
67
+ # @return [Toggly::Client]
68
+ def client
69
+ Toggly.client
70
+ end
71
+ end
72
+ end
73
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: toggly-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ops.ai
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: toggly
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ description: Rails integration for Toggly feature flags including Railtie, controller
42
+ concerns, view helpers, Rack middleware, and generators.
43
+ email:
44
+ - support@ops.ai
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - lib/toggly-rails.rb
53
+ - lib/toggly/rails/cache_snapshot_provider.rb
54
+ - lib/toggly/rails/configuration.rb
55
+ - lib/toggly/rails/context_builder.rb
56
+ - lib/toggly/rails/controller_concern.rb
57
+ - lib/toggly/rails/generators/toggly/install/install_generator.rb
58
+ - lib/toggly/rails/generators/toggly/install/templates/toggly.rb.erb
59
+ - lib/toggly/rails/middleware.rb
60
+ - lib/toggly/rails/railtie.rb
61
+ - lib/toggly/rails/tasks.rake
62
+ - lib/toggly/rails/testing.rb
63
+ - lib/toggly/rails/version.rb
64
+ - lib/toggly/rails/view_helpers.rb
65
+ homepage: https://toggly.io
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://toggly.io
70
+ source_code_uri: https://github.com/ops-ai/toggly-ruby
71
+ changelog_uri: https://github.com/ops-ai/toggly-ruby/blob/main/CHANGELOG.md
72
+ documentation_uri: https://docs.toggly.io/sdks/ruby
73
+ rubygems_mfa_required: 'true'
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.0.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.5.22
90
+ signing_key:
91
+ specification_version: 4
92
+ summary: Rails integration for Toggly feature flags
93
+ test_files: []