subflag-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.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Result of a flag evaluation with full details
6
+ #
7
+ # @example
8
+ # result = Subflag.flags(user: current_user).evaluate(:max_projects, default: 3)
9
+ # result.value # => 100
10
+ # result.variant # => "premium"
11
+ # result.reason # => :targeting_match
12
+ # result.flag_key # => "max-projects"
13
+ # result.default? # => false
14
+ # result.error? # => false
15
+ #
16
+ class EvaluationResult
17
+ # @return [Object] The resolved flag value
18
+ attr_reader :value
19
+
20
+ # @return [String, nil] The variant name that was selected
21
+ attr_reader :variant
22
+
23
+ # @return [Symbol] The reason for this evaluation result
24
+ # Possible values: :default, :targeting_match, :static, :split, :error, :unknown
25
+ attr_reader :reason
26
+
27
+ # @return [String] The flag key that was evaluated
28
+ attr_reader :flag_key
29
+
30
+ # @return [Symbol, nil] Error code if evaluation failed
31
+ attr_reader :error_code
32
+
33
+ # @return [String, nil] Error message if evaluation failed
34
+ attr_reader :error_message
35
+
36
+ def initialize(value:, variant: nil, reason: :unknown, flag_key:, error_code: nil, error_message: nil)
37
+ @value = value
38
+ @variant = variant
39
+ @reason = reason
40
+ @flag_key = flag_key
41
+ @error_code = error_code
42
+ @error_message = error_message
43
+ end
44
+
45
+ # Check if the default value was returned
46
+ #
47
+ # @return [Boolean]
48
+ def default?
49
+ reason == :default
50
+ end
51
+
52
+ # Check if there was an error during evaluation
53
+ #
54
+ # @return [Boolean]
55
+ def error?
56
+ reason == :error || !error_code.nil?
57
+ end
58
+
59
+ # Check if the value came from targeting rules
60
+ #
61
+ # @return [Boolean]
62
+ def targeted?
63
+ reason == :targeting_match
64
+ end
65
+
66
+ # Convert to hash representation
67
+ #
68
+ # @return [Hash]
69
+ def to_h
70
+ {
71
+ value: value,
72
+ variant: variant,
73
+ reason: reason,
74
+ flag_key: flag_key,
75
+ error_code: error_code,
76
+ error_message: error_message
77
+ }.compact
78
+ end
79
+
80
+ # Build from OpenFeature evaluation details
81
+ #
82
+ # @param details [Hash] OpenFeature evaluation details hash
83
+ # @param flag_key [String] The flag key
84
+ # @return [EvaluationResult]
85
+ def self.from_openfeature(details, flag_key:)
86
+ new(
87
+ value: details[:value],
88
+ variant: details[:variant],
89
+ reason: details[:reason] || :unknown,
90
+ flag_key: flag_key,
91
+ error_code: details[:error_code],
92
+ error_message: details[:error_message]
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Dynamic flag accessor using method_missing
6
+ #
7
+ # Provides a clean DSL for accessing flags:
8
+ #
9
+ # @example Boolean flags (? suffix) - default is optional (false)
10
+ # Subflag.flags.new_checkout?
11
+ # Subflag.flags.new_checkout?(default: true)
12
+ #
13
+ # @example Value flags - default is REQUIRED
14
+ # Subflag.flags.homepage_headline(default: "Welcome")
15
+ # Subflag.flags.max_projects(default: 3)
16
+ # Subflag.flags.tax_rate(default: 0.08)
17
+ # Subflag.flags.feature_limits(default: {})
18
+ #
19
+ # @example With user context
20
+ # Subflag.flags(user: current_user).max_projects(default: 3)
21
+ #
22
+ # @example Bracket access (exact flag names, default required)
23
+ # Subflag.flags["my-exact-flag", default: "value"]
24
+ #
25
+ # @example Full evaluation details
26
+ # result = Subflag.flags.evaluate(:max_projects, default: 3)
27
+ # result.value # => 100
28
+ # result.reason # => :targeting_match
29
+ #
30
+ # Flag names are automatically converted:
31
+ # - Underscores become dashes: `new_checkout` → `new-checkout`
32
+ # - Trailing ? is removed for booleans: `enabled?` → `enabled`
33
+ #
34
+ class FlagAccessor
35
+ def initialize(user: nil, context: nil)
36
+ @user = user
37
+ @context = context
38
+ end
39
+
40
+ # Bracket access for exact flag names (no conversion)
41
+ #
42
+ # @param flag_name [String, Symbol] The exact flag name
43
+ # @param default [Object] Default value (required)
44
+ # @return [Object] The flag value
45
+ # @raise [ArgumentError] If default is not provided
46
+ #
47
+ # @example
48
+ # Subflag.flags["my-exact-flag", default: "value"]
49
+ #
50
+ def [](flag_name, default:)
51
+ flag_key = flag_name.to_s
52
+ client.value(flag_key, user: @user, context: @context, default: default)
53
+ end
54
+
55
+ # Get full evaluation details for a flag
56
+ #
57
+ # @param flag_name [String, Symbol] The flag name
58
+ # @param default [Object] Default value (required)
59
+ # @return [EvaluationResult] Full evaluation result
60
+ # @raise [ArgumentError] If default is not provided
61
+ #
62
+ # @example
63
+ # result = Subflag.flags.evaluate(:max_projects, default: 3)
64
+ # result.value # => 100
65
+ # result.variant # => "premium"
66
+ # result.reason # => :targeting_match
67
+ #
68
+ def evaluate(flag_name, default:)
69
+ flag_key = normalize_flag_name(flag_name)
70
+ client.evaluate(flag_key, user: @user, context: @context, default: default)
71
+ end
72
+
73
+ # Handle dynamic flag access
74
+ #
75
+ # Method names ending in ? are treated as boolean flags (default: false).
76
+ # All other methods require a default: keyword argument.
77
+ #
78
+ # @example Boolean (default optional)
79
+ # flags.new_checkout? # default: false
80
+ # flags.new_checkout?(default: true)
81
+ #
82
+ # @example Value (default required)
83
+ # flags.max_projects(default: 3)
84
+ # flags.headline(default: "Hi")
85
+ #
86
+ def method_missing(method_name, *args, **kwargs, &block)
87
+ flag_key = normalize_flag_name(method_name)
88
+
89
+ if method_name.to_s.end_with?("?")
90
+ # Boolean flag - default is optional (false)
91
+ default = kwargs.fetch(:default, false)
92
+ client.enabled?(flag_key, user: @user, context: @context, default: default)
93
+ else
94
+ # Value flag - default is required
95
+ unless kwargs.key?(:default)
96
+ raise ArgumentError, "default is required: Subflag.flags.#{method_name}(default: <value>)"
97
+ end
98
+ client.value(flag_key, user: @user, context: @context, default: kwargs[:default])
99
+ end
100
+ end
101
+
102
+ def respond_to_missing?(method_name, include_private = false)
103
+ true
104
+ end
105
+
106
+ private
107
+
108
+ def client
109
+ Subflag::Rails.client
110
+ end
111
+
112
+ # Convert Ruby method name to flag key
113
+ #
114
+ # - Removes trailing ? for boolean methods
115
+ # - Converts underscores to dashes
116
+ #
117
+ # @param method_name [Symbol, String] The method/flag name
118
+ # @return [String] The normalized flag key
119
+ #
120
+ # @example
121
+ # normalize_flag_name(:new_checkout?) # => "new-checkout"
122
+ # normalize_flag_name(:max_projects) # => "max-projects"
123
+ #
124
+ def normalize_flag_name(method_name)
125
+ method_name.to_s.chomp("?").tr("_", "-")
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Helpers for using Subflag in controllers and views
6
+ #
7
+ # All helpers automatically use `current_user` for targeting if available.
8
+ # Override with `user: nil` or `user: other_user` when needed.
9
+ #
10
+ # @example Controller
11
+ # class ProjectsController < ApplicationController
12
+ # def index
13
+ # if subflag_enabled?(:new_dashboard)
14
+ # # show new dashboard
15
+ # end
16
+ # @max_projects = subflag_value(:max_projects, default: 3)
17
+ # end
18
+ # end
19
+ #
20
+ # @example View
21
+ # <% if subflag_enabled?(:new_checkout) %>
22
+ # <%= render "new_checkout" %>
23
+ # <% end %>
24
+ # <h1><%= subflag_value(:headline, default: "Welcome") %></h1>
25
+ #
26
+ # @example Flag accessor for multiple checks
27
+ # flags = subflag_for
28
+ # if flags.beta_feature?
29
+ # max = flags.max_projects(default: 3)
30
+ # end
31
+ #
32
+ module Helpers
33
+ # Check if a boolean flag is enabled
34
+ #
35
+ # Automatically scoped to current_user if available.
36
+ #
37
+ # @param flag_name [Symbol, String] The flag name (underscores → dashes)
38
+ # @param user [Object, nil, :auto] User for targeting (default: current_user)
39
+ # @param context [Hash, nil] Additional context attributes
40
+ # @param default [Boolean] Default value (optional, defaults to false)
41
+ # @return [Boolean]
42
+ #
43
+ # @example
44
+ # <% if subflag_enabled?(:new_checkout) %>
45
+ # <% if subflag_enabled?(:admin_feature, user: nil) %> <!-- no user context -->
46
+ #
47
+ def subflag_enabled?(flag_name, user: :auto, context: nil, default: false)
48
+ resolved = resolve_user(user)
49
+ Subflag.flags(user: resolved, context: context).public_send(:"#{flag_name}?", default: default)
50
+ end
51
+
52
+ # Get a flag value (default required)
53
+ #
54
+ # Automatically scoped to current_user if available.
55
+ #
56
+ # @param flag_name [Symbol, String] The flag name (underscores → dashes)
57
+ # @param default [Object] Default value (required - determines type)
58
+ # @param user [Object, nil, :auto] User for targeting (default: current_user)
59
+ # @param context [Hash, nil] Additional context attributes
60
+ # @return [Object] The flag value
61
+ #
62
+ # @example
63
+ # <%= subflag_value(:headline, default: "Welcome") %>
64
+ # <%= subflag_value(:max_items, user: nil, default: 10) %>
65
+ #
66
+ def subflag_value(flag_name, default:, user: :auto, context: nil)
67
+ resolved = resolve_user(user)
68
+ Subflag.flags(user: resolved, context: context).public_send(flag_name, default: default)
69
+ end
70
+
71
+ # Get a flag accessor, optionally for a specific user
72
+ #
73
+ # Automatically scoped to current_user if no user provided.
74
+ #
75
+ # @param user [Object, nil, :auto] User for targeting (default: current_user)
76
+ # @param context [Hash, nil] Additional context attributes
77
+ # @return [FlagAccessor] A flag accessor
78
+ #
79
+ # @example Using current_user automatically
80
+ # <% flags = subflag_for %>
81
+ # <% if flags.beta_feature? %>
82
+ # <h1><%= flags.welcome_message(default: "Hello!") %></h1>
83
+ # <% end %>
84
+ #
85
+ # @example Without user context
86
+ # <% flags = subflag_for(nil) %>
87
+ #
88
+ # @example With specific user
89
+ # <% flags = subflag_for(admin_user) %>
90
+ #
91
+ def subflag_for(user = :auto, context: nil)
92
+ resolved = resolve_user(user)
93
+ Subflag.flags(user: resolved, context: context)
94
+ end
95
+
96
+ private
97
+
98
+ # Resolve user parameter - use current_user if :auto and available
99
+ #
100
+ # @param user [Object, nil, :auto]
101
+ # @return [Object, nil]
102
+ def resolve_user(user)
103
+ return user unless user == :auto
104
+ respond_to?(:current_user, true) ? current_user : nil
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Railtie for automatic Rails integration
6
+ #
7
+ # This handles:
8
+ # - Registering helpers in views AND controllers
9
+ # - Auto-configuring from credentials if available
10
+ #
11
+ class Railtie < ::Rails::Railtie
12
+ # Include helpers in views and controllers
13
+ initializer "subflag.helpers" do
14
+ ActiveSupport.on_load(:action_view) do
15
+ include Subflag::Rails::Helpers
16
+ end
17
+
18
+ ActiveSupport.on_load(:action_controller_base) do
19
+ include Subflag::Rails::Helpers
20
+ end
21
+
22
+ ActiveSupport.on_load(:action_controller_api) do
23
+ include Subflag::Rails::Helpers
24
+ end
25
+ end
26
+
27
+ # Auto-configure from credentials
28
+ initializer "subflag.configure" do
29
+ config.after_initialize do
30
+ next if Subflag::Rails.configuration.api_key
31
+
32
+ api_key = ::Rails.application.credentials.dig(:subflag, :api_key) ||
33
+ ::Rails.application.credentials.subflag_api_key ||
34
+ ENV["SUBFLAG_API_KEY"]
35
+
36
+ if api_key
37
+ Subflag::Rails.configure do |c|
38
+ c.api_key = api_key
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Per-request cache for flag evaluations
6
+ #
7
+ # Caches flag values for the duration of a single request to avoid
8
+ # multiple API calls for the same flag.
9
+ #
10
+ # @example Enable in application.rb
11
+ # # config/application.rb
12
+ # config.middleware.use Subflag::Rails::RequestCache::Middleware
13
+ #
14
+ # @example Or in initializer
15
+ # # config/initializers/subflag.rb
16
+ # Rails.application.config.middleware.use Subflag::Rails::RequestCache::Middleware
17
+ #
18
+ module RequestCache
19
+ class << self
20
+ # Get a cached value or yield to fetch it
21
+ #
22
+ # @param cache_key [String] Unique key for this evaluation
23
+ # @yield Block to execute if not cached
24
+ # @return [Object] Cached or freshly fetched value
25
+ def fetch(cache_key, &block)
26
+ return yield unless enabled?
27
+
28
+ cache = current_cache
29
+ return cache[cache_key] if cache.key?(cache_key)
30
+
31
+ cache[cache_key] = yield
32
+ end
33
+
34
+ # Check if request caching is active
35
+ def enabled?
36
+ Thread.current[:subflag_request_cache].is_a?(Hash)
37
+ end
38
+
39
+ # Start a new cache scope
40
+ def start
41
+ Thread.current[:subflag_request_cache] = {}
42
+ end
43
+
44
+ # End the cache scope
45
+ def clear
46
+ Thread.current[:subflag_request_cache] = nil
47
+ end
48
+
49
+ # Get current cache
50
+ def current_cache
51
+ Thread.current[:subflag_request_cache] ||= {}
52
+ end
53
+
54
+ # Get cache stats for debugging
55
+ def stats
56
+ cache = current_cache
57
+ { size: cache.size, keys: cache.keys }
58
+ end
59
+ end
60
+
61
+ # Rack middleware for per-request caching
62
+ #
63
+ # Wraps each request in a cache scope so flag evaluations
64
+ # are cached for the duration of the request.
65
+ #
66
+ class Middleware
67
+ def initialize(app)
68
+ @app = app
69
+ end
70
+
71
+ def call(env)
72
+ RequestCache.start
73
+ @app.call(env)
74
+ ensure
75
+ RequestCache.clear
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Test helpers for stubbing feature flags in specs
6
+ #
7
+ # @example RSpec setup
8
+ # # spec/rails_helper.rb
9
+ # require "subflag/rails/test_helpers"
10
+ # RSpec.configure do |config|
11
+ # config.include Subflag::Rails::TestHelpers
12
+ # end
13
+ #
14
+ # @example Minitest setup
15
+ # # test/test_helper.rb
16
+ # require "subflag/rails/test_helpers"
17
+ # class ActiveSupport::TestCase
18
+ # include Subflag::Rails::TestHelpers
19
+ # end
20
+ #
21
+ # @example Usage
22
+ # it "shows new checkout when enabled" do
23
+ # stub_subflag(:new_checkout, true)
24
+ # visit checkout_path
25
+ # expect(page).to have_content("New Checkout")
26
+ # end
27
+ #
28
+ # it "limits projects based on plan" do
29
+ # stub_subflag(:max_projects, 100)
30
+ # expect(subflag_value(:max_projects, default: 3)).to eq(100)
31
+ # end
32
+ #
33
+ module TestHelpers
34
+ # Stub a flag to return a specific value
35
+ #
36
+ # @param flag_name [Symbol, String] The flag name (underscores or dashes)
37
+ # @param value [Object] The value to return
38
+ #
39
+ # @example Boolean
40
+ # stub_subflag(:new_checkout, true)
41
+ #
42
+ # @example String
43
+ # stub_subflag(:headline, "Welcome!")
44
+ #
45
+ # @example Integer
46
+ # stub_subflag(:max_projects, 100)
47
+ #
48
+ # @example Hash
49
+ # stub_subflag(:limits, { max_items: 50 })
50
+ #
51
+ def stub_subflag(flag_name, value)
52
+ flag_key = normalize_flag_key(flag_name)
53
+ stubbed_flags[flag_key] = value
54
+ end
55
+
56
+ # Stub multiple flags at once
57
+ #
58
+ # @param flags [Hash] Flag names to values
59
+ #
60
+ # @example
61
+ # stub_subflags(
62
+ # new_checkout: true,
63
+ # max_projects: 100,
64
+ # headline: "Welcome!"
65
+ # )
66
+ #
67
+ def stub_subflags(flags)
68
+ flags.each { |name, value| stub_subflag(name, value) }
69
+ end
70
+
71
+ # Clear all stubbed flags
72
+ def clear_stubbed_subflags
73
+ stubbed_flags.clear
74
+ end
75
+
76
+ private
77
+
78
+ def stubbed_flags
79
+ @stubbed_flags ||= {}
80
+ end
81
+
82
+ def normalize_flag_key(flag_name)
83
+ flag_name.to_s.tr("_", "-")
84
+ end
85
+ end
86
+
87
+ # Stubbed client that returns test values
88
+ class StubbedClient
89
+ def initialize(stubs)
90
+ @stubs = stubs
91
+ end
92
+
93
+ def enabled?(flag_key, user: nil, context: nil, default: false)
94
+ @stubs.fetch(flag_key, default)
95
+ end
96
+
97
+ def value(flag_key, user: nil, context: nil, default:)
98
+ @stubs.fetch(flag_key, default)
99
+ end
100
+
101
+ def evaluate(flag_key, user: nil, context: nil, default:)
102
+ value = @stubs.fetch(flag_key, default)
103
+ EvaluationResult.new(
104
+ value: value,
105
+ variant: "stubbed",
106
+ reason: @stubs.key?(flag_key) ? :static : :default,
107
+ flag_key: flag_key
108
+ )
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ # Patch FlagAccessor to use stubbed values in tests
115
+ module Subflag
116
+ module Rails
117
+ class FlagAccessor
118
+ private
119
+
120
+ alias_method :original_client, :client
121
+
122
+ def client
123
+ if test_stubs_active?
124
+ StubbedClient.new(current_test_stubs)
125
+ else
126
+ original_client
127
+ end
128
+ end
129
+
130
+ def test_stubs_active?
131
+ Thread.current[:subflag_test_stubs].is_a?(Hash) &&
132
+ !Thread.current[:subflag_test_stubs].empty?
133
+ end
134
+
135
+ def current_test_stubs
136
+ Thread.current[:subflag_test_stubs] || {}
137
+ end
138
+ end
139
+
140
+ # Update TestHelpers to use thread-local storage
141
+ module TestHelpers
142
+ private
143
+
144
+ def stubbed_flags
145
+ Thread.current[:subflag_test_stubs] ||= {}
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end