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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1796467214a7c83cd0187601d5298c6b5aae3397b6b0f48122d0daecae7a78fa
4
+ data.tar.gz: 58ca3b9ba465a7ce5f4be64df8a37dc88dc98a51ee9f7411d7e35856cd419beb
5
+ SHA512:
6
+ metadata.gz: 2abda5d0854287dbada9fd0205552525baa8e5d27a7924660656009cf516c2c38a3b37aba246a39dc688896a5fec053c2d32544eda6d83aef05824893472a2a1
7
+ data.tar.gz: f08bbb6c48fc8f61f24c3e9846aa6aa53276f5cbdb4ef4b165b230eeffd948e26e7f5477bee2c63b4b1b65b2d09decf4be0c5999db2f2afd81e06bc8e35d7abb
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2025-11-30
6
+
7
+ ### Added
8
+
9
+ - Initial release
10
+ - `Subflag.flags` DSL with method_missing for clean flag access
11
+ - Boolean flags with `?` suffix (e.g., `flags.new_checkout?`)
12
+ - Typed value flags with required defaults
13
+ - `subflag_enabled?` and `subflag_value` helpers for controllers and views
14
+ - `subflag_for` helper to get a flag accessor
15
+ - Auto-scoping to `current_user` in controllers and views
16
+ - User context configuration for targeting
17
+ - Rails generator (`rails g subflag:install`)
18
+ - Auto-configuration from Rails credentials and ENV
19
+ - Bracket access for exact flag names
20
+ - `evaluate` method for full evaluation details
21
+ - Logging support with configurable levels
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Subflag
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,245 @@
1
+ # Subflag Rails
2
+
3
+ Typed feature flags for Rails. Booleans, strings, numbers, and JSON — all targetable by user.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'subflag-rails'
11
+ ```
12
+
13
+ Run the generator:
14
+
15
+ ```bash
16
+ rails generate subflag:install
17
+ ```
18
+
19
+ Add your API key to Rails credentials:
20
+
21
+ ```bash
22
+ rails credentials:edit
23
+ ```
24
+
25
+ ```yaml
26
+ subflag:
27
+ api_key: sdk-production-your-key-here
28
+ ```
29
+
30
+ Or set the `SUBFLAG_API_KEY` environment variable.
31
+
32
+ ## Usage
33
+
34
+ ### Controllers & Views
35
+
36
+ Helpers are automatically available and scoped to `current_user`:
37
+
38
+ ```ruby
39
+ # Controller
40
+ class ProjectsController < ApplicationController
41
+ def index
42
+ if subflag_enabled?(:new_dashboard)
43
+ # show new dashboard
44
+ end
45
+
46
+ @max_projects = subflag_value(:max_projects, default: 3)
47
+ end
48
+ end
49
+ ```
50
+
51
+ ```erb
52
+ <!-- View -->
53
+ <% if subflag_enabled?(:new_checkout) %>
54
+ <%= render "new_checkout" %>
55
+ <% end %>
56
+
57
+ <h1><%= subflag_value(:headline, default: "Welcome") %></h1>
58
+
59
+ <p>You can create <%= subflag_value(:max_projects, default: 3) %> projects</p>
60
+ ```
61
+
62
+ ### Flag Accessor DSL
63
+
64
+ For multiple flag checks, use the flag accessor:
65
+
66
+ ```ruby
67
+ flags = subflag_for # auto-scoped to current_user
68
+
69
+ if flags.beta_feature?
70
+ headline = flags.welcome_message(default: "Hello!")
71
+ max = flags.max_projects(default: 3)
72
+ end
73
+ ```
74
+
75
+ Or use `Subflag.flags` directly:
76
+
77
+ ```ruby
78
+ # With user context
79
+ flags = Subflag.flags(user: current_user)
80
+ flags.new_checkout? # => true/false
81
+ flags.max_projects(default: 3) # => 100
82
+
83
+ # Bracket access for exact flag names
84
+ flags["my-exact-flag", default: "value"]
85
+
86
+ # Full evaluation details
87
+ result = flags.evaluate(:max_projects, default: 3)
88
+ result.value # => 100
89
+ result.variant # => "premium"
90
+ result.reason # => :targeting_match
91
+ ```
92
+
93
+ ### Flag Types
94
+
95
+ The default value determines the expected type:
96
+
97
+ ```ruby
98
+ # Boolean (? suffix, default optional)
99
+ subflag_enabled?(:new_checkout) # default: false
100
+ subflag_enabled?(:new_checkout, default: true)
101
+
102
+ # String
103
+ subflag_value(:headline, default: "Welcome")
104
+
105
+ # Integer
106
+ subflag_value(:max_projects, default: 3)
107
+
108
+ # Float
109
+ subflag_value(:tax_rate, default: 0.08)
110
+
111
+ # Hash/Object
112
+ subflag_value(:feature_limits, default: { max_items: 10 })
113
+ ```
114
+
115
+ ### User Targeting
116
+
117
+ Configure how to extract context from user objects:
118
+
119
+ ```ruby
120
+ # config/initializers/subflag.rb
121
+ Subflag::Rails.configure do |config|
122
+ config.user_context do |user|
123
+ {
124
+ targeting_key: user.id.to_s,
125
+ email: user.email,
126
+ plan: user.subscription&.plan_name || "free",
127
+ admin: user.admin?
128
+ }
129
+ end
130
+ end
131
+ ```
132
+
133
+ Now flags can return different values based on user attributes:
134
+
135
+ ```ruby
136
+ # In Subflag dashboard: max-projects returns 3 for "free", 100 for "premium"
137
+ subflag_value(:max_projects, default: 3) # => 3 or 100 based on user's plan
138
+ ```
139
+
140
+ ### Override User Context
141
+
142
+ ```ruby
143
+ # No user context
144
+ subflag_enabled?(:public_feature, user: nil)
145
+
146
+ # Different user
147
+ subflag_value(:max_projects, user: admin_user, default: 3)
148
+ ```
149
+
150
+ ## Flag Naming
151
+
152
+ Flag names use lowercase letters, numbers, and dashes:
153
+ - Valid: `new-checkout`, `max-api-requests`, `feature1`
154
+ - Invalid: `new_checkout`, `NewCheckout`, `my flag`
155
+
156
+ In Ruby, use underscores — they're automatically converted to dashes:
157
+
158
+ ```ruby
159
+ subflag_enabled?(:new_checkout) # looks up "new-checkout"
160
+ ```
161
+
162
+ ## Testing
163
+
164
+ Stub flags in your tests:
165
+
166
+ ```ruby
167
+ # spec/rails_helper.rb (RSpec)
168
+ require "subflag/rails/test_helpers"
169
+ RSpec.configure do |config|
170
+ config.include Subflag::Rails::TestHelpers
171
+ end
172
+
173
+ # test/test_helper.rb (Minitest)
174
+ require "subflag/rails/test_helpers"
175
+ class ActiveSupport::TestCase
176
+ include Subflag::Rails::TestHelpers
177
+ end
178
+ ```
179
+
180
+ ```ruby
181
+ # In your specs/tests
182
+ it "shows new checkout when enabled" do
183
+ stub_subflag(:new_checkout, true)
184
+ stub_subflag(:max_projects, 100)
185
+
186
+ visit checkout_path
187
+ expect(page).to have_content("New Checkout")
188
+ end
189
+
190
+ # Stub multiple at once
191
+ stub_subflags(
192
+ new_checkout: true,
193
+ max_projects: 100,
194
+ headline: "Welcome!"
195
+ )
196
+ ```
197
+
198
+ ## Request Caching
199
+
200
+ Enable per-request caching to avoid multiple API calls for the same flag:
201
+
202
+ ```ruby
203
+ # config/application.rb
204
+ config.middleware.use Subflag::Rails::RequestCache::Middleware
205
+ ```
206
+
207
+ Now multiple checks for the same flag in one request hit the API only once:
208
+
209
+ ```ruby
210
+ # Without caching: 3 API calls
211
+ # With caching: 1 API call (cached for subsequent checks)
212
+ subflag_enabled?(:new_checkout) # API call
213
+ subflag_enabled?(:new_checkout) # Cache hit
214
+ subflag_enabled?(:new_checkout) # Cache hit
215
+ ```
216
+
217
+ ## Configuration
218
+
219
+ ```ruby
220
+ Subflag::Rails.configure do |config|
221
+ # API key (auto-loaded from credentials/ENV)
222
+ config.api_key = "sdk-production-..."
223
+
224
+ # API URL (default: https://api.subflag.com)
225
+ config.api_url = "https://api.subflag.com"
226
+
227
+ # Logging
228
+ config.logging_enabled = Rails.env.development?
229
+ config.log_level = :debug # :debug, :info, :warn
230
+
231
+ # User context
232
+ config.user_context do |user|
233
+ { targeting_key: user.id.to_s, plan: user.plan }
234
+ end
235
+ end
236
+ ```
237
+
238
+ ## Documentation
239
+
240
+ - [Subflag Docs](https://docs.subflag.com)
241
+ - [Rails Guide](https://docs.subflag.com/rails)
242
+
243
+ ## License
244
+
245
+ MIT
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Subflag
6
+ module Generators
7
+ # Generator for setting up Subflag in a Rails application
8
+ #
9
+ # Usage:
10
+ # rails generate subflag:install
11
+ #
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Creates a Subflag initializer and provides setup instructions"
16
+
17
+ def create_initializer
18
+ template "initializer.rb", "config/initializers/subflag.rb"
19
+ end
20
+
21
+ def show_instructions
22
+ say ""
23
+ say "Subflag installed!", :green
24
+ say ""
25
+ say "Next steps:"
26
+ say ""
27
+ say "1. Add your API key to Rails credentials:"
28
+ say " $ rails credentials:edit"
29
+ say ""
30
+ say " subflag:"
31
+ say " api_key: sdk-production-your-key-here"
32
+ say ""
33
+ say " Or set SUBFLAG_API_KEY environment variable."
34
+ say ""
35
+ say "2. Configure user context in config/initializers/subflag.rb"
36
+ say ""
37
+ say "3. Use flags in your code:"
38
+ say ""
39
+ say " # Controller (auto-scoped to current_user)"
40
+ say " if subflag_enabled?(:new_checkout)"
41
+ say " # ..."
42
+ say " end"
43
+ say ""
44
+ say " max = subflag_value(:max_projects, default: 3)"
45
+ say ""
46
+ say " # View"
47
+ say " <% if subflag_enabled?(:new_checkout) %>"
48
+ say " <%= render 'new_checkout' %>"
49
+ say " <% end %>"
50
+ say ""
51
+ say "Docs: https://docs.subflag.com/rails"
52
+ say ""
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Subflag configuration
4
+ #
5
+ # API key is automatically loaded from:
6
+ # 1. Rails credentials (subflag.api_key or subflag_api_key)
7
+ # 2. SUBFLAG_API_KEY environment variable
8
+
9
+ Subflag::Rails.configure do |config|
10
+ # Uncomment to manually set API key
11
+ # config.api_key = Rails.application.credentials.dig(:subflag, :api_key)
12
+
13
+ # Enable logging in development
14
+ config.logging_enabled = Rails.env.development?
15
+ config.log_level = :debug
16
+
17
+ # Configure user context for targeting
18
+ # This enables per-user flag values (e.g., different limits by plan)
19
+ #
20
+ # config.user_context do |user|
21
+ # {
22
+ # targeting_key: user.id.to_s,
23
+ # email: user.email,
24
+ # plan: user.subscription&.plan_name || "free",
25
+ # admin: user.admin?
26
+ # }
27
+ # end
28
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Client for evaluating feature flags
6
+ #
7
+ # This is the low-level client used by FlagAccessor.
8
+ # Most users should use `Subflag.flags` instead.
9
+ #
10
+ class Client
11
+ # Check if a boolean flag is enabled
12
+ #
13
+ # @param flag_key [String] The flag key (already normalized)
14
+ # @param user [Object, nil] The user object for targeting
15
+ # @param context [Hash, nil] Additional context attributes
16
+ # @param default [Boolean] Default value if flag not found (defaults to false)
17
+ # @return [Boolean]
18
+ def enabled?(flag_key, user: nil, context: nil, default: false)
19
+ ctx = ContextBuilder.build(user: user, context: context)
20
+ cache_key = build_cache_key(flag_key, ctx, :boolean)
21
+
22
+ result = RequestCache.fetch(cache_key) do
23
+ openfeature_client.fetch_boolean_value(flag_key, default, ctx)
24
+ end
25
+
26
+ log_evaluation(flag_key, result, default)
27
+ result
28
+ end
29
+
30
+ # Get a flag value - default is required to determine type
31
+ #
32
+ # @param flag_key [String] The flag key (already normalized)
33
+ # @param user [Object, nil] The user object for targeting
34
+ # @param context [Hash, nil] Additional context attributes
35
+ # @param default [Object] Default value (required - determines expected type)
36
+ # @return [Object] The flag value
37
+ # @raise [ArgumentError] If default is nil
38
+ def value(flag_key, user: nil, context: nil, default:)
39
+ ctx = ContextBuilder.build(user: user, context: context)
40
+ cache_key = build_cache_key(flag_key, ctx, default.class)
41
+
42
+ result = RequestCache.fetch(cache_key) do
43
+ fetch_value_by_type(flag_key, default, ctx)
44
+ end
45
+
46
+ log_evaluation(flag_key, result, default)
47
+ result
48
+ end
49
+
50
+ # Evaluate a flag and get full details - default is required
51
+ #
52
+ # @param flag_key [String] The flag key (already normalized)
53
+ # @param user [Object, nil] The user object for targeting
54
+ # @param context [Hash, nil] Additional context attributes
55
+ # @param default [Object] Default value (required - determines expected type)
56
+ # @return [EvaluationResult] Full evaluation result
57
+ # @raise [ArgumentError] If default is nil
58
+ def evaluate(flag_key, user: nil, context: nil, default:)
59
+ ctx = ContextBuilder.build(user: user, context: context)
60
+ cache_key = build_cache_key(flag_key, ctx, "details:#{default.class}")
61
+
62
+ details = RequestCache.fetch(cache_key) do
63
+ fetch_details_by_type(flag_key, default, ctx)
64
+ end
65
+
66
+ log_evaluation(flag_key, details[:value], default)
67
+ EvaluationResult.from_openfeature(details, flag_key: flag_key)
68
+ end
69
+
70
+ private
71
+
72
+ def build_cache_key(flag_key, ctx, type)
73
+ context_hash = ctx ? ctx.hash : "no_context"
74
+ "subflag:#{flag_key}:#{context_hash}:#{type}"
75
+ end
76
+
77
+ def openfeature_client
78
+ @openfeature_client ||= OpenFeature::SDK.build_client
79
+ end
80
+
81
+ def fetch_value_by_type(flag_key, default, ctx)
82
+ case default
83
+ when String
84
+ openfeature_client.fetch_string_value(flag_key, default, ctx)
85
+ when Integer
86
+ openfeature_client.fetch_integer_value(flag_key, default, ctx)
87
+ when Float
88
+ openfeature_client.fetch_float_value(flag_key, default, ctx)
89
+ when TrueClass, FalseClass
90
+ openfeature_client.fetch_boolean_value(flag_key, default, ctx)
91
+ when Hash
92
+ openfeature_client.fetch_object_value(flag_key, default, ctx)
93
+ when NilClass
94
+ raise ArgumentError, "default is required for value flags (it determines the expected type)"
95
+ else
96
+ raise ArgumentError, "Unsupported default type: #{default.class}. Use String, Integer, Float, Boolean, or Hash."
97
+ end
98
+ end
99
+
100
+ def fetch_details_by_type(flag_key, default, ctx)
101
+ case default
102
+ when String
103
+ openfeature_client.fetch_string_details(flag_key, default, ctx)
104
+ when Integer
105
+ openfeature_client.fetch_integer_details(flag_key, default, ctx)
106
+ when Float
107
+ openfeature_client.fetch_float_details(flag_key, default, ctx)
108
+ when TrueClass, FalseClass
109
+ openfeature_client.fetch_boolean_details(flag_key, default, ctx)
110
+ when Hash
111
+ openfeature_client.fetch_object_details(flag_key, default, ctx)
112
+ when NilClass
113
+ raise ArgumentError, "default is required for evaluate (it determines the expected type)"
114
+ else
115
+ raise ArgumentError, "Unsupported default type: #{default.class}. Use String, Integer, Float, Boolean, or Hash."
116
+ end
117
+ end
118
+
119
+ def configuration
120
+ Subflag::Rails.configuration
121
+ end
122
+
123
+ def log_evaluation(flag_key, result, default)
124
+ return unless configuration.logging_enabled
125
+
126
+ logger = defined?(::Rails.logger) ? ::Rails.logger : nil
127
+ return unless logger
128
+
129
+ message = "[Subflag] #{flag_key} = #{result.inspect}"
130
+ message += " (default)" if result == default
131
+
132
+ case configuration.log_level
133
+ when :info
134
+ logger.info(message)
135
+ when :warn
136
+ logger.warn(message)
137
+ else
138
+ logger.debug(message)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Configuration for Subflag Rails integration
6
+ #
7
+ # @example Basic configuration
8
+ # Subflag::Rails.configure do |config|
9
+ # config.api_key = Rails.application.credentials.subflag_api_key
10
+ # end
11
+ #
12
+ # @example With user context
13
+ # Subflag::Rails.configure do |config|
14
+ # config.api_key = Rails.application.credentials.subflag_api_key
15
+ # config.user_context do |user|
16
+ # {
17
+ # targeting_key: user.id.to_s,
18
+ # email: user.email,
19
+ # plan: user.subscription&.plan_name,
20
+ # admin: user.admin?
21
+ # }
22
+ # end
23
+ # end
24
+ #
25
+ class Configuration
26
+ # @return [String, nil] The Subflag API key
27
+ attr_accessor :api_key
28
+
29
+ # @return [String] The Subflag API URL (defaults to production)
30
+ attr_accessor :api_url
31
+
32
+ # @return [Boolean] Whether to log flag evaluations
33
+ attr_accessor :logging_enabled
34
+
35
+ # @return [Symbol] Log level for flag evaluations (:debug, :info, :warn)
36
+ attr_accessor :log_level
37
+
38
+ def initialize
39
+ @api_key = nil
40
+ @api_url = "https://api.subflag.com"
41
+ @user_context_block = nil
42
+ @logging_enabled = false
43
+ @log_level = :debug
44
+ end
45
+
46
+ # Configure how to extract context from a user object
47
+ #
48
+ # @yield [user] Block that receives a user object and returns context hash
49
+ # @yieldparam user [Object] The user object passed to flag methods
50
+ # @yieldreturn [Hash] Context hash with targeting_key and attributes
51
+ #
52
+ # @example
53
+ # config.user_context do |user|
54
+ # {
55
+ # targeting_key: user.id.to_s,
56
+ # email: user.email,
57
+ # plan: user.plan
58
+ # }
59
+ # end
60
+ def user_context(&block)
61
+ @user_context_block = block if block_given?
62
+ @user_context_block
63
+ end
64
+
65
+ # Check if user context is configured
66
+ #
67
+ # @return [Boolean]
68
+ def user_context_configured?
69
+ !@user_context_block.nil?
70
+ end
71
+
72
+ # Build context from a user object using the configured block
73
+ #
74
+ # @param user [Object] The user object
75
+ # @return [Hash, nil] The context hash or nil if no user/block
76
+ def build_user_context(user)
77
+ return nil unless user && @user_context_block
78
+
79
+ @user_context_block.call(user)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Builds OpenFeature evaluation context from user objects and additional attributes
6
+ class ContextBuilder
7
+ # Build an OpenFeature context hash
8
+ #
9
+ # @param user [Object, nil] The user object for targeting
10
+ # @param context [Hash, nil] Additional context attributes
11
+ # @return [Hash, nil] The combined context or nil if empty
12
+ def self.build(user: nil, context: nil)
13
+ new(user: user, context: context).build
14
+ end
15
+
16
+ def initialize(user: nil, context: nil)
17
+ @user = user
18
+ @context = context || {}
19
+ end
20
+
21
+ # Build the context hash
22
+ #
23
+ # @return [Hash, nil]
24
+ def build
25
+ result = {}
26
+
27
+ # Add user context if configured
28
+ if @user
29
+ user_context = configuration.build_user_context(@user)
30
+ result.merge!(user_context) if user_context
31
+ end
32
+
33
+ # Merge in additional context (overrides user context)
34
+ result.merge!(@context) if @context.is_a?(Hash)
35
+
36
+ # Return nil if empty (no context to send)
37
+ result.empty? ? nil : normalize_context(result)
38
+ end
39
+
40
+ private
41
+
42
+ def configuration
43
+ Subflag::Rails.configuration
44
+ end
45
+
46
+ # Normalize context to OpenFeature format
47
+ #
48
+ # @param ctx [Hash] The raw context
49
+ # @return [Hash] Normalized context with targeting_key at top level
50
+ def normalize_context(ctx)
51
+ # Ensure targeting_key is a string
52
+ if ctx[:targeting_key]
53
+ ctx[:targeting_key] = ctx[:targeting_key].to_s
54
+ elsif ctx["targeting_key"]
55
+ ctx[:targeting_key] = ctx.delete("targeting_key").to_s
56
+ end
57
+
58
+ ctx.transform_keys(&:to_sym)
59
+ end
60
+ end
61
+ end
62
+ end