subflag-rails 0.2.0 → 0.3.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: 2d5585ee53dcf0c1864f3d9bf5845a41cd8e3c884fe11ca457bc51b3ded8cfcd
4
+ data.tar.gz: 1014353a80ee041027dd501aea4035f7043b873dbf75506678e2852b948f436a
5
5
  SHA512:
6
- metadata.gz: bda7f1465a5f3eabf2baa0190c372c4687b731ad5cb43a9d11851b78b9d23457c9dbdffb65f2235f92f61b18841ed1bddc8d5b6eae27d89ac4b9802db6b81df3
7
- data.tar.gz: 950362e174920f95c08309a8cfc406e1249612f554f0cd51df13f580c8f6a73492cca56e2ff597dcba94fbb6914d66e069569a23c5df0c139ded9c7983561860
6
+ metadata.gz: 7a0b5adb5b8df94ed4e07ba41e47b4430b719b2cf741695b6f9029239aefa6437535d36fb08d9b2f12d84911126cdfa45d15429b2c024f0d93c694f6ee5e77e8
7
+ data.tar.gz: 582032e633830bb9e5867ae6883545a4ba5fd0ece16556a542f9de3513a5c0853f4ce7a121ad4af3d3ea6bc901ff136abc9f77f12eaa37789d64fa77bd11a750
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
@@ -214,6 +214,84 @@ subflag_enabled?(:new_checkout) # Cache hit
214
214
  subflag_enabled?(:new_checkout) # Cache hit
215
215
  ```
216
216
 
217
+ ## Bulk Flag Evaluation (Prefetch)
218
+
219
+ For optimal performance, prefetch all flags for a user in a single API call. This is especially useful when your page checks multiple flags:
220
+
221
+ ```ruby
222
+ # config/application.rb (required)
223
+ config.middleware.use Subflag::Rails::RequestCache::Middleware
224
+ ```
225
+
226
+ ```ruby
227
+ class ApplicationController < ActionController::Base
228
+ before_action :prefetch_feature_flags
229
+
230
+ private
231
+
232
+ def prefetch_feature_flags
233
+ subflag_prefetch # Fetches all flags for current_user in one API call
234
+ end
235
+ end
236
+ ```
237
+
238
+ Now all subsequent flag lookups use the cache — no additional API calls:
239
+
240
+ ```ruby
241
+ # In your controller/view - all lookups are instant (cache hits)
242
+ subflag_enabled?(:new_checkout) # Cache hit
243
+ subflag_value(:max_projects, default: 3) # Cache hit
244
+ subflag_value(:headline, default: "Hi") # Cache hit
245
+ ```
246
+
247
+ ### How It Works
248
+
249
+ 1. **Single API call**: `subflag_prefetch` calls `/sdk/evaluate-all` to fetch all flags
250
+ 2. **Per-request cache**: Results are stored in `RequestCache` for the duration of the request
251
+ 3. **Zero-latency lookups**: Subsequent `subflag_enabled?` and `subflag_value` calls read from cache
252
+
253
+ ### Prefetch Without current_user
254
+
255
+ ```ruby
256
+ # No user context
257
+ subflag_prefetch(nil)
258
+
259
+ # With specific user
260
+ subflag_prefetch(admin_user)
261
+
262
+ # With additional context
263
+ subflag_prefetch(current_user, context: { device: "mobile" })
264
+ ```
265
+
266
+ ### Cross-Request Caching
267
+
268
+ By default, prefetched flags are only cached for the current request. To cache across multiple requests using `Rails.cache`, set a TTL:
269
+
270
+ ```ruby
271
+ # config/initializers/subflag.rb
272
+ Subflag::Rails.configure do |config|
273
+ config.api_key = Rails.application.credentials.subflag_api_key
274
+ config.cache_ttl = 30.seconds # Cache flags in Rails.cache for 30 seconds
275
+ end
276
+ ```
277
+
278
+ With `cache_ttl` set:
279
+ - First request fetches from API and stores in `Rails.cache`
280
+ - Subsequent requests (within TTL) read from `Rails.cache` — no API call
281
+ - After TTL expires, next request fetches fresh data
282
+
283
+ This significantly reduces API load for high-traffic applications. Choose a TTL that balances freshness with performance — 30 seconds is a good starting point.
284
+
285
+ ### Direct API
286
+
287
+ You can also use the module method directly:
288
+
289
+ ```ruby
290
+ Subflag.prefetch_flags(user: current_user)
291
+ # or
292
+ Subflag::Rails.prefetch_flags(user: current_user)
293
+ ```
294
+
217
295
  ## Configuration
218
296
 
219
297
  ```ruby
@@ -224,6 +302,10 @@ Subflag::Rails.configure do |config|
224
302
  # API URL (default: https://api.subflag.com)
225
303
  config.api_url = "https://api.subflag.com"
226
304
 
305
+ # Cross-request caching via Rails.cache (optional)
306
+ # When set, prefetched flags are cached for this duration
307
+ config.cache_ttl = 30.seconds
308
+
227
309
  # Logging
228
310
  config.logging_enabled = Rails.env.development?
229
311
  config.log_level = :debug # :debug, :info, :warn
@@ -8,6 +8,33 @@ module Subflag
8
8
  # Most users should use `Subflag.flags` instead.
9
9
  #
10
10
  class Client
11
+ # Prefetch all flags for a user/context
12
+ #
13
+ # Fetches all flags in a single API call and caches them.
14
+ # Subsequent flag lookups will use the cached values.
15
+ #
16
+ # @param user [Object, nil] The user object for targeting
17
+ # @param context [Hash, nil] Additional context attributes
18
+ # @return [Array<Hash>] Array of prefetched flag results (for inspection)
19
+ #
20
+ # @example
21
+ # Subflag::Rails.client.prefetch_all(user: current_user)
22
+ # # Subsequent lookups use cache - no API calls
23
+ # subflag_enabled?(:new_feature)
24
+ #
25
+ def prefetch_all(user: nil, context: nil)
26
+ ctx = ContextBuilder.build(user: user, context: context)
27
+ context_hash = ctx ? ctx.hash : "no_context"
28
+
29
+ # Use Rails.cache for cross-request caching if enabled
30
+ if configuration.rails_cache_enabled?
31
+ return prefetch_with_rails_cache(ctx, context_hash)
32
+ end
33
+
34
+ # Otherwise fetch directly from API (per-request cache only)
35
+ prefetch_from_api(ctx, context_hash)
36
+ end
37
+
11
38
  # Check if a boolean flag is enabled
12
39
  #
13
40
  # @param flag_key [String] The flag key (already normalized)
@@ -17,6 +44,14 @@ module Subflag
17
44
  # @return [Boolean]
18
45
  def enabled?(flag_key, user: nil, context: nil, default: false)
19
46
  ctx = ContextBuilder.build(user: user, context: context)
47
+
48
+ # Check prefetch cache first
49
+ prefetched = get_prefetched_value(flag_key, ctx, :boolean)
50
+ if !prefetched.nil?
51
+ log_evaluation(flag_key, prefetched, default)
52
+ return prefetched
53
+ end
54
+
20
55
  cache_key = build_cache_key(flag_key, ctx, :boolean)
21
56
 
22
57
  result = RequestCache.fetch(cache_key) do
@@ -37,6 +72,15 @@ module Subflag
37
72
  # @raise [ArgumentError] If default is nil
38
73
  def value(flag_key, user: nil, context: nil, default:)
39
74
  ctx = ContextBuilder.build(user: user, context: context)
75
+
76
+ # Check prefetch cache first
77
+ expected_type = type_from_default(default)
78
+ prefetched = get_prefetched_value(flag_key, ctx, expected_type)
79
+ if !prefetched.nil?
80
+ log_evaluation(flag_key, prefetched, default)
81
+ return prefetched
82
+ end
83
+
40
84
  cache_key = build_cache_key(flag_key, ctx, default.class)
41
85
 
42
86
  result = RequestCache.fetch(cache_key) do
@@ -57,6 +101,14 @@ module Subflag
57
101
  # @raise [ArgumentError] If default is nil
58
102
  def evaluate(flag_key, user: nil, context: nil, default:)
59
103
  ctx = ContextBuilder.build(user: user, context: context)
104
+
105
+ # Check prefetch cache first - returns full EvaluationResult
106
+ prefetched_result = get_prefetched_result(flag_key, ctx)
107
+ if prefetched_result
108
+ log_evaluation(flag_key, prefetched_result.value, default)
109
+ return EvaluationResult.from_subflag(prefetched_result)
110
+ end
111
+
60
112
  cache_key = build_cache_key(flag_key, ctx, "details:#{default.class}")
61
113
 
62
114
  details = RequestCache.fetch(cache_key) do
@@ -69,6 +121,46 @@ module Subflag
69
121
 
70
122
  private
71
123
 
124
+ # Fetch flags from API and populate RequestCache
125
+ def prefetch_from_api(ctx, context_hash)
126
+ subflag_context = build_subflag_context(ctx)
127
+ results = subflag_client.evaluate_all(context: subflag_context)
128
+
129
+ # Cache each result in RequestCache for this request
130
+ results.each do |result|
131
+ cache_prefetched_result(result, context_hash)
132
+ end
133
+
134
+ results.map(&:to_h)
135
+ end
136
+
137
+ # Fetch flags using Rails.cache with TTL, falling back to API on cache miss
138
+ def prefetch_with_rails_cache(ctx, context_hash)
139
+ rails_cache_key = "subflag:all_flags:#{context_hash}"
140
+
141
+ cached_data = ::Rails.cache.fetch(rails_cache_key, expires_in: configuration.cache_ttl) do
142
+ # Cache miss - fetch from API
143
+ subflag_context = build_subflag_context(ctx)
144
+ results = subflag_client.evaluate_all(context: subflag_context)
145
+ results.map(&:to_h)
146
+ end
147
+
148
+ # Populate RequestCache from cached data (whether from Rails.cache or fresh fetch)
149
+ populate_request_cache_from_data(cached_data, context_hash)
150
+
151
+ cached_data
152
+ end
153
+
154
+ # Populate RequestCache from cached hash data
155
+ def populate_request_cache_from_data(data_array, context_hash)
156
+ return unless RequestCache.enabled?
157
+
158
+ data_array.each do |result_hash|
159
+ result = ::Subflag::EvaluationResult.from_response(result_hash)
160
+ cache_prefetched_result(result, context_hash)
161
+ end
162
+ end
163
+
72
164
  def build_cache_key(flag_key, ctx, type)
73
165
  context_hash = ctx ? ctx.hash : "no_context"
74
166
  "subflag:#{flag_key}:#{context_hash}:#{type}"
@@ -138,6 +230,81 @@ module Subflag
138
230
  logger.debug(message)
139
231
  end
140
232
  end
233
+
234
+ # Build Subflag::EvaluationContext from OpenFeature context
235
+ def build_subflag_context(openfeature_ctx)
236
+ return nil unless openfeature_ctx
237
+
238
+ ::Subflag::EvaluationContext.from_openfeature(openfeature_ctx)
239
+ end
240
+
241
+ # Get or create the Subflag HTTP client for direct API calls
242
+ def subflag_client
243
+ @subflag_client ||= ::Subflag::Client.new(
244
+ api_url: configuration.api_url,
245
+ api_key: configuration.api_key
246
+ )
247
+ end
248
+
249
+ # Cache a prefetched result for subsequent lookups
250
+ def cache_prefetched_result(result, context_hash)
251
+ # Store with a prefetch-specific key that includes the raw result
252
+ prefetch_key = "subflag:prefetch:#{result.flag_key}:#{context_hash}"
253
+ RequestCache.current_cache[prefetch_key] = result
254
+ end
255
+
256
+ # Check if a flag was prefetched and return its value
257
+ def get_prefetched_value(flag_key, ctx, expected_type)
258
+ return nil unless RequestCache.enabled?
259
+
260
+ context_hash = ctx ? ctx.hash : "no_context"
261
+ prefetch_key = "subflag:prefetch:#{flag_key}:#{context_hash}"
262
+ result = RequestCache.current_cache[prefetch_key]
263
+
264
+ return nil unless result
265
+
266
+ # Convert value to expected type if needed
267
+ convert_prefetched_value(result.value, expected_type)
268
+ end
269
+
270
+ # Get the full prefetched result for a flag (for evaluate method)
271
+ def get_prefetched_result(flag_key, ctx)
272
+ return nil unless RequestCache.enabled?
273
+
274
+ context_hash = ctx ? ctx.hash : "no_context"
275
+ prefetch_key = "subflag:prefetch:#{flag_key}:#{context_hash}"
276
+ RequestCache.current_cache[prefetch_key]
277
+ end
278
+
279
+ # Convert prefetched value to expected type
280
+ def convert_prefetched_value(value, expected_type)
281
+ case expected_type
282
+ when :boolean
283
+ value == true || value == false ? value : nil
284
+ when :string
285
+ value.is_a?(String) ? value : nil
286
+ when :integer
287
+ value.is_a?(Numeric) ? value.to_i : nil
288
+ when :float
289
+ value.is_a?(Numeric) ? value.to_f : nil
290
+ when :object
291
+ value.is_a?(Hash) ? value : nil
292
+ else
293
+ value
294
+ end
295
+ end
296
+
297
+ # Get expected type from default value
298
+ def type_from_default(default)
299
+ case default
300
+ when TrueClass, FalseClass then :boolean
301
+ when String then :string
302
+ when Integer then :integer
303
+ when Float then :float
304
+ when Hash then :object
305
+ else :unknown
306
+ end
307
+ end
141
308
  end
142
309
  end
143
310
  end
@@ -35,12 +35,28 @@ module Subflag
35
35
  # @return [Symbol] Log level for flag evaluations (:debug, :info, :warn)
36
36
  attr_accessor :log_level
37
37
 
38
+ # @return [Integer, ActiveSupport::Duration, nil] TTL for cross-request caching
39
+ # When set, prefetched flags are cached in Rails.cache for this duration.
40
+ # Set to nil to disable cross-request caching (default).
41
+ attr_accessor :cache_ttl
42
+
38
43
  def initialize
39
44
  @api_key = nil
40
45
  @api_url = "https://api.subflag.com"
41
46
  @user_context_block = nil
42
47
  @logging_enabled = false
43
48
  @log_level = :debug
49
+ @cache_ttl = nil
50
+ end
51
+
52
+ # Check if cross-request caching via Rails.cache is enabled
53
+ #
54
+ # @return [Boolean]
55
+ def rails_cache_enabled?
56
+ return false unless @cache_ttl && @cache_ttl.to_i > 0
57
+ return false unless defined?(::Rails.cache) && ::Rails.cache.present?
58
+
59
+ true
44
60
  end
45
61
 
46
62
  # Configure how to extract context from a user object
@@ -92,6 +92,22 @@ module Subflag
92
92
  error_message: details[:error_message]
93
93
  )
94
94
  end
95
+
96
+ # Build from Subflag::EvaluationResult (from Ruby provider)
97
+ #
98
+ # @param result [Subflag::EvaluationResult] The provider's evaluation result
99
+ # @return [EvaluationResult]
100
+ def self.from_subflag(result)
101
+ # Convert uppercase reason string to lowercase symbol
102
+ reason = result.reason&.downcase&.to_sym || :unknown
103
+
104
+ new(
105
+ value: result.value,
106
+ variant: result.variant,
107
+ reason: reason,
108
+ flag_key: result.flag_key
109
+ )
110
+ end
95
111
  end
96
112
  end
97
113
  end
@@ -93,6 +93,36 @@ module Subflag
93
93
  Subflag.flags(user: resolved, context: context)
94
94
  end
95
95
 
96
+ # Prefetch all flags for a user in a single API call
97
+ #
98
+ # Call this early in a request (e.g., in a before_action) to fetch
99
+ # all flags at once. Subsequent flag lookups will use the cached values.
100
+ #
101
+ # Automatically scoped to current_user if available.
102
+ #
103
+ # @param user [Object, nil, :auto] User for targeting (default: current_user)
104
+ # @param context [Hash, nil] Additional context attributes
105
+ # @return [Array<Hash>] Array of prefetched flag results (for inspection)
106
+ #
107
+ # @example In ApplicationController
108
+ # class ApplicationController < ActionController::Base
109
+ # before_action :prefetch_feature_flags
110
+ #
111
+ # private
112
+ #
113
+ # def prefetch_feature_flags
114
+ # subflag_prefetch # Uses current_user automatically
115
+ # end
116
+ # end
117
+ #
118
+ # @example Without user context
119
+ # subflag_prefetch(nil)
120
+ #
121
+ def subflag_prefetch(user = :auto, context: nil)
122
+ resolved = resolve_user(user)
123
+ Subflag.prefetch_flags(user: resolved, context: context)
124
+ end
125
+
96
126
  private
97
127
 
98
128
  # Resolve user parameter - use current_user if :auto and available
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/subflag/rails.rb CHANGED
@@ -48,6 +48,26 @@ module Subflag
48
48
  @client ||= Client.new
49
49
  end
50
50
 
51
+ # Prefetch all flags for a user/context in a single API call
52
+ #
53
+ # Call this early in a request to fetch all flags at once.
54
+ # Subsequent flag lookups will use the cached values.
55
+ #
56
+ # @param user [Object, nil] The user object for targeting
57
+ # @param context [Hash, nil] Additional context attributes
58
+ # @return [Array<Hash>] Array of prefetched flag results (for inspection)
59
+ #
60
+ # @example Prefetch in a controller
61
+ # before_action :prefetch_flags
62
+ #
63
+ # def prefetch_flags
64
+ # Subflag::Rails.prefetch_flags(user: current_user)
65
+ # end
66
+ #
67
+ def prefetch_flags(user: nil, context: nil)
68
+ client.prefetch_all(user: user, context: context)
69
+ end
70
+
51
71
  # Reset configuration (primarily for testing)
52
72
  def reset!
53
73
  @configuration = Configuration.new
@@ -92,5 +112,20 @@ module Subflag
92
112
  def flags(user: nil, context: nil)
93
113
  Rails::FlagAccessor.new(user: user, context: context)
94
114
  end
115
+
116
+ # Prefetch all flags for a user/context in a single API call
117
+ #
118
+ # @param user [Object, nil] The user object for targeting
119
+ # @param context [Hash, nil] Additional context attributes
120
+ # @return [Array<Hash>] Array of prefetched flag results
121
+ #
122
+ # @example
123
+ # Subflag.prefetch_flags(user: current_user)
124
+ # # Subsequent lookups use cache
125
+ # Subflag.flags(user: current_user).new_feature?(default: false)
126
+ #
127
+ def prefetch_flags(user: nil, context: nil)
128
+ Rails.prefetch_flags(user: user, context: context)
129
+ end
95
130
  end
96
131
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subflag-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subflag
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-01 00:00:00.000000000 Z
11
+ date: 2025-12-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: subflag-openfeature-provider
@@ -16,7 +16,7 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '0.1'
19
+ version: 0.3.1
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
22
  version: '1.0'
@@ -26,7 +26,7 @@ dependencies:
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: '0.1'
29
+ version: 0.3.1
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '1.0'