subflag-rails 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b994b3be254b327ab4c2d7f94ed4a30a9af6a47af2c19b58089b02b5d8487e5
4
- data.tar.gz: 267fb6f5df4a770f7133ee475ae364118bc2459031137434160cf17ddf91033c
3
+ metadata.gz: da40f567a605d55076aaeabed1edd404bb41ba2a1703c56595e42611ebf4aaa4
4
+ data.tar.gz: 39df667c30500ebf560e90694213f05b479d60f488575c7f2503409bc640222d
5
5
  SHA512:
6
- metadata.gz: bda7f1465a5f3eabf2baa0190c372c4687b731ad5cb43a9d11851b78b9d23457c9dbdffb65f2235f92f61b18841ed1bddc8d5b6eae27d89ac4b9802db6b81df3
7
- data.tar.gz: 950362e174920f95c08309a8cfc406e1249612f554f0cd51df13f580c8f6a73492cca56e2ff597dcba94fbb6914d66e069569a23c5df0c139ded9c7983561860
6
+ metadata.gz: 0bb2499ecf945fb2586918f5c3e57cdda5e6ce2f143eb4b6d1d674370412ccee6f6e8fe5d992705ac4b0b28de88465ebe7e140fad1a2c71f691982ebe363c4ce
7
+ data.tar.gz: f255c179b502557624bfcfc81bc06743c13898bef9d7b5225e4519e097fbdfc7b88af2b2729088883747cb3a123f1a51fff521618527e68e7e77ca494212be32
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2025-12-07
6
+
7
+ ### Added
8
+
9
+ - **Bulk flag evaluation**: `subflag_prefetch` helper fetches all flags in a single API call
10
+ - **Cross-request caching**: `config.cache_ttl` enables caching via `Rails.cache` with configurable TTL
11
+ - `Subflag.prefetch_flags` and `Subflag::Rails.prefetch_flags` module methods
12
+
13
+ ### Changed
14
+
15
+ - Requires `subflag-openfeature-provider` >= 0.3.1
16
+
5
17
  ## [0.2.0] - 2025-11-30
6
18
 
7
19
  ### Changed
data/README.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Subflag Rails
2
2
 
3
- Typed feature flags for Rails. Booleans, strings, numbers, and JSON — all targetable by user.
3
+ Typed feature flags for Rails. Booleans, strings, numbers, and JSON — with pluggable backends.
4
+
5
+ [Subflag](https://subflag.com)
6
+
7
+ ## Backends
8
+
9
+ Choose where your flags live:
10
+
11
+ | Backend | Use Case | Flags Stored In |
12
+ |---------|----------|-----------------|
13
+ | `:subflag` | Production with dashboard, environments, targeting | Subflag Cloud |
14
+ | `:active_record` | Self-hosted, no external dependencies | Your database |
15
+ | `:memory` | Testing and development | In-memory hash |
16
+
17
+ **Same API regardless of backend:**
18
+
19
+ ```ruby
20
+ subflag_enabled?(:new_checkout) # Works with any backend
21
+ subflag_value(:max_projects, default: 3) # Works with any backend
22
+ ```
4
23
 
5
24
  ## Installation
6
25
 
@@ -8,9 +27,14 @@ Add to your Gemfile:
8
27
 
9
28
  ```ruby
10
29
  gem 'subflag-rails'
30
+
31
+ # If using Subflag Cloud (backend: :subflag), also add:
32
+ gem 'subflag-openfeature-provider'
11
33
  ```
12
34
 
13
- Run the generator:
35
+ ### Option 1: Subflag Cloud (Default)
36
+
37
+ Dashboard, environments, percentage rollouts, and user targeting.
14
38
 
15
39
  ```bash
16
40
  rails generate subflag:install
@@ -29,6 +53,38 @@ subflag:
29
53
 
30
54
  Or set the `SUBFLAG_API_KEY` environment variable.
31
55
 
56
+ ### Option 2: ActiveRecord (Self-Hosted)
57
+
58
+ Flags stored in your database. No external dependencies.
59
+
60
+ ```bash
61
+ rails generate subflag:install --backend=active_record
62
+ rails db:migrate
63
+ ```
64
+
65
+ Create flags directly:
66
+
67
+ ```ruby
68
+ Subflag::Rails::Flag.create!(key: "new-checkout", value: "true", value_type: "boolean")
69
+ Subflag::Rails::Flag.create!(key: "max-projects", value: "100", value_type: "integer")
70
+ Subflag::Rails::Flag.create!(key: "welcome-message", value: "Hello!", value_type: "string")
71
+ ```
72
+
73
+ ### Option 3: Memory (Testing)
74
+
75
+ In-memory flags for tests and local development.
76
+
77
+ ```bash
78
+ rails generate subflag:install --backend=memory
79
+ ```
80
+
81
+ Set flags programmatically:
82
+
83
+ ```ruby
84
+ Subflag::Rails.provider.set(:new_checkout, true)
85
+ Subflag::Rails.provider.set(:max_projects, 100)
86
+ ```
87
+
32
88
  ## Usage
33
89
 
34
90
  ### Controllers & Views
@@ -159,82 +215,190 @@ In Ruby, use underscores — they're automatically converted to dashes:
159
215
  subflag_enabled?(:new_checkout) # looks up "new-checkout"
160
216
  ```
161
217
 
162
- ## Testing
218
+ ## Request Caching
163
219
 
164
- Stub flags in your tests:
220
+ Enable per-request caching to avoid multiple API calls for the same flag:
165
221
 
166
222
  ```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
223
+ # config/application.rb
224
+ config.middleware.use Subflag::Rails::RequestCache::Middleware
225
+ ```
172
226
 
173
- # test/test_helper.rb (Minitest)
174
- require "subflag/rails/test_helpers"
175
- class ActiveSupport::TestCase
176
- include Subflag::Rails::TestHelpers
227
+ Now multiple checks for the same flag in one request hit the API only once:
228
+
229
+ ```ruby
230
+ # Without caching: 3 API calls
231
+ # With caching: 1 API call (cached for subsequent checks)
232
+ subflag_enabled?(:new_checkout) # API call
233
+ subflag_enabled?(:new_checkout) # Cache hit
234
+ subflag_enabled?(:new_checkout) # Cache hit
235
+ ```
236
+
237
+ ## Cross-Request Caching
238
+
239
+ By default, prefetched flags are only cached for the current request. To cache across multiple requests using `Rails.cache`, set a TTL:
240
+
241
+ ```ruby
242
+ # config/initializers/subflag.rb
243
+ Subflag::Rails.configure do |config|
244
+ config.api_key = Rails.application.credentials.subflag_api_key
245
+ config.cache_ttl = 30.seconds # Cache flags in Rails.cache for 30 seconds
177
246
  end
178
247
  ```
179
248
 
249
+ With `cache_ttl` set:
250
+ - First request fetches from API and stores in `Rails.cache`
251
+ - Subsequent requests (within TTL) read from `Rails.cache` — no API call
252
+ - After TTL expires, next request fetches fresh data
253
+
254
+ This significantly reduces API load for high-traffic applications. Choose a TTL that balances freshness with performance — 30 seconds is a good starting point.
255
+
256
+ ## Bulk Flag Evaluation (Prefetch)
257
+
258
+ For optimal performance, prefetch all flags for a user in a single API call. This is especially useful when your page checks multiple flags:
259
+
180
260
  ```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)
261
+ # config/application.rb (required)
262
+ config.middleware.use Subflag::Rails::RequestCache::Middleware
263
+ ```
185
264
 
186
- visit checkout_path
187
- expect(page).to have_content("New Checkout")
265
+ ```ruby
266
+ class ApplicationController < ActionController::Base
267
+ before_action :prefetch_feature_flags
268
+
269
+ private
270
+
271
+ def prefetch_feature_flags
272
+ subflag_prefetch # Fetches all flags for current_user in one API call
273
+ end
188
274
  end
275
+ ```
189
276
 
190
- # Stub multiple at once
191
- stub_subflags(
192
- new_checkout: true,
193
- max_projects: 100,
194
- headline: "Welcome!"
195
- )
277
+ Now all subsequent flag lookups use the cache — no additional API calls:
278
+
279
+ ```ruby
280
+ # In your controller/view - all lookups are instant (cache hits)
281
+ subflag_enabled?(:new_checkout) # Cache hit
282
+ subflag_value(:max_projects, default: 3) # Cache hit
283
+ subflag_value(:headline, default: "Hi") # Cache hit
196
284
  ```
197
285
 
198
- ## Request Caching
286
+ ### How It Works
199
287
 
200
- Enable per-request caching to avoid multiple API calls for the same flag:
288
+ 1. **Single API call**: `subflag_prefetch` calls `/sdk/evaluate-all` to fetch all flags
289
+ 2. **Per-request cache**: Results are stored in `RequestCache` for the duration of the request
290
+ 3. **Zero-latency lookups**: Subsequent `subflag_enabled?` and `subflag_value` calls read from cache
291
+
292
+ ### Prefetch Without current_user
201
293
 
202
294
  ```ruby
203
- # config/application.rb
204
- config.middleware.use Subflag::Rails::RequestCache::Middleware
295
+ # No user context
296
+ subflag_prefetch(nil)
297
+
298
+ # With specific user
299
+ subflag_prefetch(admin_user)
300
+
301
+ # With additional context
302
+ subflag_prefetch(current_user, context: { device: "mobile" })
205
303
  ```
206
304
 
207
- Now multiple checks for the same flag in one request hit the API only once:
305
+ ### Direct API
306
+
307
+ You can also use the module method directly:
208
308
 
209
309
  ```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
310
+ Subflag.prefetch_flags(user: current_user)
311
+ # or
312
+ Subflag::Rails.prefetch_flags(user: current_user)
215
313
  ```
216
314
 
217
315
  ## Configuration
218
316
 
219
317
  ```ruby
220
318
  Subflag::Rails.configure do |config|
221
- # API key (auto-loaded from credentials/ENV)
319
+ # Backend: :subflag (cloud), :active_record (self-hosted), :memory (testing)
320
+ config.backend = :subflag
321
+
322
+ # API key - required for :subflag backend
222
323
  config.api_key = "sdk-production-..."
223
324
 
224
325
  # API URL (default: https://api.subflag.com)
225
326
  config.api_url = "https://api.subflag.com"
226
327
 
328
+ # Cross-request caching via Rails.cache (optional, :subflag backend only)
329
+ # When set, prefetched flags are cached for this duration
330
+ config.cache_ttl = 30.seconds
331
+
227
332
  # Logging
228
333
  config.logging_enabled = Rails.env.development?
229
334
  config.log_level = :debug # :debug, :info, :warn
230
335
 
231
- # User context
336
+ # User context - works with all backends
232
337
  config.user_context do |user|
233
338
  { targeting_key: user.id.to_s, plan: user.plan }
234
339
  end
235
340
  end
236
341
  ```
237
342
 
343
+ ### ActiveRecord Flag Model
344
+
345
+ When using `backend: :active_record`, flags are stored in the `subflag_flags` table:
346
+
347
+ | Column | Type | Description |
348
+ |--------|------|-------------|
349
+ | `key` | string | Flag name (lowercase, dashes, e.g., `new-checkout`) |
350
+ | `value` | text | The flag value as a string |
351
+ | `value_type` | string | Type: `boolean`, `string`, `integer`, `float`, `object` |
352
+ | `enabled` | boolean | Whether the flag is active (default: true) |
353
+ | `description` | text | Optional description |
354
+
355
+ ```ruby
356
+ # Create flags
357
+ Subflag::Rails::Flag.create!(key: "max-projects", value: "100", value_type: "integer")
358
+
359
+ # Query flags
360
+ Subflag::Rails::Flag.enabled.find_each { |f| puts "#{f.key}: #{f.typed_value}" }
361
+
362
+ # Disable a flag
363
+ Subflag::Rails::Flag.find_by(key: "new-checkout")&.update!(enabled: false)
364
+ ```
365
+
366
+ ## Testing
367
+
368
+ Stub flags in your tests:
369
+
370
+ ```ruby
371
+ # spec/rails_helper.rb (RSpec)
372
+ require "subflag/rails/test_helpers"
373
+ RSpec.configure do |config|
374
+ config.include Subflag::Rails::TestHelpers
375
+ end
376
+
377
+ # test/test_helper.rb (Minitest)
378
+ require "subflag/rails/test_helpers"
379
+ class ActiveSupport::TestCase
380
+ include Subflag::Rails::TestHelpers
381
+ end
382
+ ```
383
+
384
+ ```ruby
385
+ # In your specs/tests
386
+ it "shows new checkout when enabled" do
387
+ stub_subflag(:new_checkout, true)
388
+ stub_subflag(:max_projects, 100)
389
+
390
+ visit checkout_path
391
+ expect(page).to have_content("New Checkout")
392
+ end
393
+
394
+ # Stub multiple at once
395
+ stub_subflags(
396
+ new_checkout: true,
397
+ max_projects: 100,
398
+ headline: "Welcome!"
399
+ )
400
+ ```
401
+
238
402
  ## Documentation
239
403
 
240
404
  - [Subflag Docs](https://docs.subflag.com)
@@ -7,49 +7,113 @@ module Subflag
7
7
  # Generator for setting up Subflag in a Rails application
8
8
  #
9
9
  # Usage:
10
- # rails generate subflag:install
10
+ # rails generate subflag:install # Default: Subflag Cloud
11
+ # rails generate subflag:install --backend=subflag # Explicit: Subflag Cloud
12
+ # rails generate subflag:install --backend=active_record # Self-hosted DB
13
+ # rails generate subflag:install --backend=memory # Testing only
11
14
  #
12
15
  class InstallGenerator < ::Rails::Generators::Base
16
+ include ::Rails::Generators::Migration if defined?(::Rails::Generators::Migration)
17
+
13
18
  source_root File.expand_path("templates", __dir__)
14
19
 
15
- desc "Creates a Subflag initializer and provides setup instructions"
20
+ desc "Creates a Subflag initializer and optionally a migration for ActiveRecord backend"
21
+
22
+ class_option :backend, type: :string, default: "subflag",
23
+ desc: "Backend to use: subflag (cloud), active_record (self-hosted), or memory (testing)"
24
+
25
+ def self.next_migration_number(dirname)
26
+ if defined?(::ActiveRecord::Generators::Base)
27
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
28
+ else
29
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
30
+ end
31
+ end
16
32
 
17
33
  def create_initializer
18
- template "initializer.rb", "config/initializers/subflag.rb"
34
+ template "initializer.rb.tt", "config/initializers/subflag.rb"
35
+ end
36
+
37
+ def create_migration
38
+ return unless options[:backend] == "active_record"
39
+
40
+ migration_template "create_subflag_flags.rb.tt",
41
+ "db/migrate/create_subflag_flags.rb"
19
42
  end
20
43
 
21
44
  def show_instructions
22
45
  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 ""
46
+
47
+ case options[:backend]
48
+ when "active_record"
49
+ say "Subflag installed with ActiveRecord backend!", :green
50
+ say ""
51
+ say "Next steps:"
52
+ say ""
53
+ say "1. Run the migration:"
54
+ say " $ rails db:migrate"
55
+ say ""
56
+ say "2. Create your first flag:"
57
+ say ""
58
+ say " Subflag::Rails::Flag.create!("
59
+ say " key: 'new-checkout',"
60
+ say " value: 'true',"
61
+ say " value_type: 'boolean'"
62
+ say " )"
63
+ say ""
64
+ say "3. Use flags in your code:"
65
+ say ""
66
+ say " if subflag_enabled?(:new_checkout)"
67
+ say " # ..."
68
+ say " end"
69
+ say ""
70
+ say "When you're ready for a dashboard, environments, and user targeting:"
71
+ say " https://subflag.com", :yellow
72
+ say ""
73
+
74
+ when "memory"
75
+ say "Subflag installed with Memory backend!", :green
76
+ say ""
77
+ say "Note: Memory backend is for testing only. Flags reset on restart."
78
+ say ""
79
+ say "Set flags in your tests or initializer:"
80
+ say ""
81
+ say " Subflag::Rails.provider.set(:new_checkout, true)"
82
+ say " Subflag::Rails.provider.set(:max_projects, 100)"
83
+ say ""
84
+ say "Use flags:"
85
+ say ""
86
+ say " subflag_enabled?(:new_checkout) # => true"
87
+ say " subflag_value(:max_projects, default: 3) # => 100"
88
+ say ""
89
+
90
+ else # subflag (cloud)
91
+ say "Subflag installed!", :green
92
+ say ""
93
+ say "Next steps:"
94
+ say ""
95
+ say "1. Add your API key to Rails credentials:"
96
+ say " $ rails credentials:edit"
97
+ say ""
98
+ say " subflag:"
99
+ say " api_key: sdk-production-your-key-here"
100
+ say ""
101
+ say " Or set SUBFLAG_API_KEY environment variable."
102
+ say ""
103
+ say "2. Configure user context in config/initializers/subflag.rb"
104
+ say ""
105
+ say "3. Use flags in your code:"
106
+ say ""
107
+ say " # Controller (auto-scoped to current_user)"
108
+ say " if subflag_enabled?(:new_checkout)"
109
+ say " # ..."
110
+ say " end"
111
+ say ""
112
+ say " max = subflag_value(:max_projects, default: 3)"
113
+ say ""
114
+ say "Docs: https://docs.subflag.com/rails"
115
+ say ""
116
+ end
53
117
  end
54
118
  end
55
119
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSubflagFlags < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :subflag_flags do |t|
6
+ t.string :key, null: false
7
+ t.string :value_type, null: false, default: "boolean"
8
+ t.text :value, null: false
9
+ t.boolean :enabled, null: false, default: true
10
+ t.text :description
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :subflag_flags, :key, unique: true
16
+ end
17
+ end
@@ -2,17 +2,31 @@
2
2
 
3
3
  # Subflag configuration
4
4
  #
5
+ <% if options[:backend] == "subflag" -%>
5
6
  # API key is automatically loaded from:
6
7
  # 1. Rails credentials (subflag.api_key or subflag_api_key)
7
8
  # 2. SUBFLAG_API_KEY environment variable
9
+ <% elsif options[:backend] == "active_record" -%>
10
+ # Using ActiveRecord backend - flags stored in subflag_flags table
11
+ <% else -%>
12
+ # Using Memory backend - flags stored in memory (testing only)
13
+ <% end -%>
8
14
 
9
15
  Subflag::Rails.configure do |config|
10
- # Uncomment to manually set API key
11
- # config.api_key = Rails.application.credentials.dig(:subflag, :api_key)
16
+ # Backend: :subflag (cloud), :active_record (self-hosted), :memory (testing)
17
+ config.backend = :<%= options[:backend] %>
18
+ <% if options[:backend] == "subflag" -%>
19
+
20
+ # Your Subflag API key
21
+ # Get one at https://subflag.com
22
+ config.api_key = ENV["SUBFLAG_API_KEY"] || Rails.application.credentials.dig(:subflag, :api_key)
23
+ <% end -%>
24
+ <% if options[:backend] != "memory" -%>
12
25
 
13
26
  # Enable logging in development
14
27
  config.logging_enabled = Rails.env.development?
15
28
  config.log_level = :debug
29
+ <% end -%>
16
30
 
17
31
  # Configure user context for targeting
18
32
  # This enables per-user flag values (e.g., different limits by plan)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ module Backends
6
+ # Provider that reads flags from your Rails database
7
+ #
8
+ # Stores flags in a `subflag_flags` table with typed values.
9
+ # Perfect for teams who want self-hosted feature flags without external dependencies.
10
+ #
11
+ # @example
12
+ # Subflag::Rails.configure do |config|
13
+ # config.backend = :active_record
14
+ # end
15
+ #
16
+ # # Create a flag
17
+ # Subflag::Rails::Flag.create!(
18
+ # key: "max-projects",
19
+ # value: "100",
20
+ # value_type: "integer",
21
+ # enabled: true
22
+ # )
23
+ #
24
+ # # Use it
25
+ # subflag_value(:max_projects, default: 3) # => 100
26
+ #
27
+ class ActiveRecordProvider
28
+ def metadata
29
+ { name: "Subflag ActiveRecord Provider" }
30
+ end
31
+
32
+ def init; end
33
+ def shutdown; end
34
+
35
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
36
+ resolve(flag_key, default_value, :boolean)
37
+ end
38
+
39
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
40
+ resolve(flag_key, default_value, :string)
41
+ end
42
+
43
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
44
+ resolve(flag_key, default_value, :number)
45
+ end
46
+
47
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
48
+ resolve(flag_key, default_value, :integer)
49
+ end
50
+
51
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
52
+ resolve(flag_key, default_value, :float)
53
+ end
54
+
55
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
56
+ resolve(flag_key, default_value, :object)
57
+ end
58
+
59
+ private
60
+
61
+ def resolve(flag_key, default_value, expected_type)
62
+ flag = Subflag::Rails::Flag.find_by(key: flag_key)
63
+
64
+ unless flag&.enabled?
65
+ return resolution(default_value, reason: :default)
66
+ end
67
+
68
+ value = flag.typed_value(expected_type)
69
+ resolution(value, reason: :static, variant: "default")
70
+ end
71
+
72
+ def resolution(value, reason:, variant: nil)
73
+ OpenFeature::SDK::Provider::ResolutionDetails.new(
74
+ value: value,
75
+ reason: reason,
76
+ variant: variant
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end