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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ module Backends
6
+ # In-memory provider for testing and development
7
+ #
8
+ # Flags are stored in a hash and reset when the process restarts.
9
+ # Useful for unit tests and local development without external dependencies.
10
+ #
11
+ # @example In tests
12
+ # Subflag::Rails.configure do |config|
13
+ # config.backend = :memory
14
+ # end
15
+ #
16
+ # # Set flags directly
17
+ # Subflag::Rails.provider.set(:new_checkout, true)
18
+ # Subflag::Rails.provider.set(:max_projects, 100)
19
+ #
20
+ # # Use them
21
+ # subflag_enabled?(:new_checkout) # => true
22
+ #
23
+ class MemoryProvider
24
+ def initialize
25
+ @flags = {}
26
+ end
27
+
28
+ def metadata
29
+ { name: "Subflag Memory Provider" }
30
+ end
31
+
32
+ def init; end
33
+ def shutdown; end
34
+
35
+ # Set a flag value programmatically
36
+ #
37
+ # @param key [String, Symbol] The flag key (underscores converted to dashes)
38
+ # @param value [Object] The flag value
39
+ # @param enabled [Boolean] Whether the flag is enabled (default: true)
40
+ def set(key, value, enabled: true)
41
+ @flags[normalize_key(key)] = { value: value, enabled: enabled }
42
+ end
43
+
44
+ # Clear all flags
45
+ def clear
46
+ @flags.clear
47
+ end
48
+
49
+ # Get all flags (for debugging)
50
+ def all
51
+ @flags.dup
52
+ end
53
+
54
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
55
+ resolve(flag_key, default_value)
56
+ end
57
+
58
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
59
+ resolve(flag_key, default_value)
60
+ end
61
+
62
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
63
+ resolve(flag_key, default_value)
64
+ end
65
+
66
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
67
+ resolve(flag_key, default_value)
68
+ end
69
+
70
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
71
+ resolve(flag_key, default_value)
72
+ end
73
+
74
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
75
+ resolve(flag_key, default_value)
76
+ end
77
+
78
+ private
79
+
80
+ def normalize_key(key)
81
+ key.to_s.tr("_", "-")
82
+ end
83
+
84
+ def resolve(flag_key, default_value)
85
+ flag = @flags[flag_key.to_s]
86
+
87
+ unless flag && flag[:enabled]
88
+ return resolution(default_value, reason: :default)
89
+ end
90
+
91
+ resolution(flag[:value], reason: :static, variant: "default")
92
+ end
93
+
94
+ def resolution(value, reason:, variant: nil)
95
+ OpenFeature::SDK::Provider::ResolutionDetails.new(
96
+ value: value,
97
+ reason: reason,
98
+ variant: variant
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ module Backends
6
+ # Provider wrapper for Subflag Cloud SaaS
7
+ #
8
+ # Delegates to the standalone subflag-openfeature-provider gem.
9
+ # This is the default backend when using Subflag::Rails.
10
+ #
11
+ # @example
12
+ # Subflag::Rails.configure do |config|
13
+ # config.backend = :subflag
14
+ # config.api_key = "sdk-production-..."
15
+ # end
16
+ #
17
+ class SubflagProvider
18
+ def initialize(api_key:, api_url:)
19
+ require "subflag"
20
+ @provider = ::Subflag::Provider.new(api_key: api_key, api_url: api_url)
21
+ end
22
+
23
+ def metadata
24
+ @provider.metadata
25
+ end
26
+
27
+ def init
28
+ @provider.init
29
+ end
30
+
31
+ def shutdown
32
+ @provider.shutdown
33
+ end
34
+
35
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
36
+ @provider.fetch_boolean_value(
37
+ flag_key: flag_key,
38
+ default_value: default_value,
39
+ evaluation_context: evaluation_context
40
+ )
41
+ end
42
+
43
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
44
+ @provider.fetch_string_value(
45
+ flag_key: flag_key,
46
+ default_value: default_value,
47
+ evaluation_context: evaluation_context
48
+ )
49
+ end
50
+
51
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
52
+ @provider.fetch_number_value(
53
+ flag_key: flag_key,
54
+ default_value: default_value,
55
+ evaluation_context: evaluation_context
56
+ )
57
+ end
58
+
59
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
60
+ @provider.fetch_integer_value(
61
+ flag_key: flag_key,
62
+ default_value: default_value,
63
+ evaluation_context: evaluation_context
64
+ )
65
+ end
66
+
67
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
68
+ @provider.fetch_float_value(
69
+ flag_key: flag_key,
70
+ default_value: default_value,
71
+ evaluation_context: evaluation_context
72
+ )
73
+ end
74
+
75
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
76
+ @provider.fetch_object_value(
77
+ flag_key: flag_key,
78
+ default_value: default_value,
79
+ evaluation_context: evaluation_context
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -2,12 +2,47 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
+ # Lightweight struct for caching prefetched flag results
6
+ # Used by ActiveRecord and Memory backends where we don't have Subflag::EvaluationResult
7
+ PrefetchedFlag = Struct.new(:flag_key, :value, :reason, :variant, keyword_init: true)
8
+
5
9
  # Client for evaluating feature flags
6
10
  #
7
11
  # This is the low-level client used by FlagAccessor.
8
12
  # Most users should use `Subflag.flags` instead.
9
13
  #
10
14
  class Client
15
+ # Prefetch all flags for a user/context
16
+ #
17
+ # Fetches all flags and caches them for subsequent lookups.
18
+ # Behavior varies by backend:
19
+ # - :subflag — Single API call to fetch all flags
20
+ # - :active_record — Single DB query to load all enabled flags
21
+ # - :memory — No-op (flags already in memory)
22
+ #
23
+ # @param user [Object, nil] The user object for targeting
24
+ # @param context [Hash, nil] Additional context attributes
25
+ # @return [Array<Hash>] Array of prefetched flag results (for inspection)
26
+ #
27
+ # @example
28
+ # Subflag::Rails.client.prefetch_all(user: current_user)
29
+ # # Subsequent lookups use cache - no API calls
30
+ # subflag_enabled?(:new_feature)
31
+ #
32
+ def prefetch_all(user: nil, context: nil)
33
+ case configuration.backend
34
+ when :subflag
35
+ prefetch_from_subflag_api(user: user, context: context)
36
+ when :active_record
37
+ prefetch_from_active_record(user: user, context: context)
38
+ when :memory
39
+ # Already in memory, nothing to prefetch
40
+ []
41
+ else
42
+ []
43
+ end
44
+ end
45
+
11
46
  # Check if a boolean flag is enabled
12
47
  #
13
48
  # @param flag_key [String] The flag key (already normalized)
@@ -17,6 +52,14 @@ module Subflag
17
52
  # @return [Boolean]
18
53
  def enabled?(flag_key, user: nil, context: nil, default: false)
19
54
  ctx = ContextBuilder.build(user: user, context: context)
55
+
56
+ # Check prefetch cache first
57
+ prefetched = get_prefetched_value(flag_key, ctx, :boolean)
58
+ if !prefetched.nil?
59
+ log_evaluation(flag_key, prefetched, default)
60
+ return prefetched
61
+ end
62
+
20
63
  cache_key = build_cache_key(flag_key, ctx, :boolean)
21
64
 
22
65
  result = RequestCache.fetch(cache_key) do
@@ -37,6 +80,15 @@ module Subflag
37
80
  # @raise [ArgumentError] If default is nil
38
81
  def value(flag_key, user: nil, context: nil, default:)
39
82
  ctx = ContextBuilder.build(user: user, context: context)
83
+
84
+ # Check prefetch cache first
85
+ expected_type = type_from_default(default)
86
+ prefetched = get_prefetched_value(flag_key, ctx, expected_type)
87
+ if !prefetched.nil?
88
+ log_evaluation(flag_key, prefetched, default)
89
+ return prefetched
90
+ end
91
+
40
92
  cache_key = build_cache_key(flag_key, ctx, default.class)
41
93
 
42
94
  result = RequestCache.fetch(cache_key) do
@@ -57,6 +109,14 @@ module Subflag
57
109
  # @raise [ArgumentError] If default is nil
58
110
  def evaluate(flag_key, user: nil, context: nil, default:)
59
111
  ctx = ContextBuilder.build(user: user, context: context)
112
+
113
+ # Check prefetch cache first - returns full EvaluationResult
114
+ prefetched_result = get_prefetched_result(flag_key, ctx)
115
+ if prefetched_result
116
+ log_evaluation(flag_key, prefetched_result.value, default)
117
+ return EvaluationResult.from_subflag(prefetched_result)
118
+ end
119
+
60
120
  cache_key = build_cache_key(flag_key, ctx, "details:#{default.class}")
61
121
 
62
122
  details = RequestCache.fetch(cache_key) do
@@ -69,6 +129,84 @@ module Subflag
69
129
 
70
130
  private
71
131
 
132
+ # Prefetch flags from Subflag Cloud API
133
+ def prefetch_from_subflag_api(user:, context:)
134
+ ctx = ContextBuilder.build(user: user, context: context)
135
+ context_hash = ctx ? ctx.hash : "no_context"
136
+
137
+ # Use Rails.cache for cross-request caching if enabled
138
+ if configuration.rails_cache_enabled?
139
+ return prefetch_with_rails_cache(ctx, context_hash)
140
+ end
141
+
142
+ # Otherwise fetch directly from API (per-request cache only)
143
+ prefetch_from_api(ctx, context_hash)
144
+ end
145
+
146
+ # Fetch flags from API and populate RequestCache
147
+ def prefetch_from_api(ctx, context_hash)
148
+ subflag_context = build_subflag_context(ctx)
149
+ results = subflag_client.evaluate_all(context: subflag_context)
150
+
151
+ # Cache each result in RequestCache for this request
152
+ results.each do |result|
153
+ cache_prefetched_result(result, context_hash)
154
+ end
155
+
156
+ results.map(&:to_h)
157
+ end
158
+
159
+ # Fetch flags using Rails.cache with TTL, falling back to API on cache miss
160
+ def prefetch_with_rails_cache(ctx, context_hash)
161
+ rails_cache_key = "subflag:all_flags:#{context_hash}"
162
+
163
+ cached_data = ::Rails.cache.fetch(rails_cache_key, expires_in: configuration.cache_ttl) do
164
+ # Cache miss - fetch from API
165
+ subflag_context = build_subflag_context(ctx)
166
+ results = subflag_client.evaluate_all(context: subflag_context)
167
+ results.map(&:to_h)
168
+ end
169
+
170
+ # Populate RequestCache from cached data (whether from Rails.cache or fresh fetch)
171
+ populate_request_cache_from_data(cached_data, context_hash)
172
+
173
+ cached_data
174
+ end
175
+
176
+ # Populate RequestCache from cached hash data (Subflag API)
177
+ def populate_request_cache_from_data(data_array, context_hash)
178
+ return unless RequestCache.enabled?
179
+
180
+ data_array.each do |result_hash|
181
+ result = ::Subflag::EvaluationResult.from_response(result_hash)
182
+ cache_prefetched_result(result, context_hash)
183
+ end
184
+ end
185
+
186
+ # Prefetch flags from ActiveRecord database
187
+ # Loads all enabled flags in one query and caches their values
188
+ def prefetch_from_active_record(user:, context:)
189
+ return [] unless RequestCache.enabled?
190
+
191
+ ctx = ContextBuilder.build(user: user, context: context)
192
+ context_hash = ctx ? ctx.hash : "no_context"
193
+
194
+ prefetched = []
195
+
196
+ Subflag::Rails::Flag.enabled.find_each do |flag|
197
+ prefetch_key = "subflag:prefetch:#{flag.key}:#{context_hash}"
198
+ RequestCache.current_cache[prefetch_key] = PrefetchedFlag.new(
199
+ flag_key: flag.key,
200
+ value: flag.typed_value,
201
+ reason: "STATIC",
202
+ variant: "default"
203
+ )
204
+ prefetched << { flag_key: flag.key, value: flag.typed_value }
205
+ end
206
+
207
+ prefetched
208
+ end
209
+
72
210
  def build_cache_key(flag_key, ctx, type)
73
211
  context_hash = ctx ? ctx.hash : "no_context"
74
212
  "subflag:#{flag_key}:#{context_hash}:#{type}"
@@ -138,6 +276,81 @@ module Subflag
138
276
  logger.debug(message)
139
277
  end
140
278
  end
279
+
280
+ # Build Subflag::EvaluationContext from OpenFeature context
281
+ def build_subflag_context(openfeature_ctx)
282
+ return nil unless openfeature_ctx
283
+
284
+ ::Subflag::EvaluationContext.from_openfeature(openfeature_ctx)
285
+ end
286
+
287
+ # Get or create the Subflag HTTP client for direct API calls
288
+ def subflag_client
289
+ @subflag_client ||= ::Subflag::Client.new(
290
+ api_url: configuration.api_url,
291
+ api_key: configuration.api_key
292
+ )
293
+ end
294
+
295
+ # Cache a prefetched result for subsequent lookups
296
+ def cache_prefetched_result(result, context_hash)
297
+ # Store with a prefetch-specific key that includes the raw result
298
+ prefetch_key = "subflag:prefetch:#{result.flag_key}:#{context_hash}"
299
+ RequestCache.current_cache[prefetch_key] = result
300
+ end
301
+
302
+ # Check if a flag was prefetched and return its value
303
+ def get_prefetched_value(flag_key, ctx, expected_type)
304
+ return nil unless RequestCache.enabled?
305
+
306
+ context_hash = ctx ? ctx.hash : "no_context"
307
+ prefetch_key = "subflag:prefetch:#{flag_key}:#{context_hash}"
308
+ result = RequestCache.current_cache[prefetch_key]
309
+
310
+ return nil unless result
311
+
312
+ # Convert value to expected type if needed
313
+ convert_prefetched_value(result.value, expected_type)
314
+ end
315
+
316
+ # Get the full prefetched result for a flag (for evaluate method)
317
+ def get_prefetched_result(flag_key, ctx)
318
+ return nil unless RequestCache.enabled?
319
+
320
+ context_hash = ctx ? ctx.hash : "no_context"
321
+ prefetch_key = "subflag:prefetch:#{flag_key}:#{context_hash}"
322
+ RequestCache.current_cache[prefetch_key]
323
+ end
324
+
325
+ # Convert prefetched value to expected type
326
+ def convert_prefetched_value(value, expected_type)
327
+ case expected_type
328
+ when :boolean
329
+ value == true || value == false ? value : nil
330
+ when :string
331
+ value.is_a?(String) ? value : nil
332
+ when :integer
333
+ value.is_a?(Numeric) ? value.to_i : nil
334
+ when :float
335
+ value.is_a?(Numeric) ? value.to_f : nil
336
+ when :object
337
+ value.is_a?(Hash) ? value : nil
338
+ else
339
+ value
340
+ end
341
+ end
342
+
343
+ # Get expected type from default value
344
+ def type_from_default(default)
345
+ case default
346
+ when TrueClass, FalseClass then :boolean
347
+ when String then :string
348
+ when Integer then :integer
349
+ when Float then :float
350
+ when Hash then :object
351
+ else :unknown
352
+ end
353
+ end
141
354
  end
142
355
  end
143
356
  end
@@ -23,6 +23,14 @@ module Subflag
23
23
  # end
24
24
  #
25
25
  class Configuration
26
+ VALID_BACKENDS = %i[subflag active_record memory].freeze
27
+
28
+ # @return [Symbol] Backend to use (:subflag, :active_record, :memory)
29
+ # - :subflag — Subflag Cloud SaaS (default)
30
+ # - :active_record — Self-hosted, flags stored in your database
31
+ # - :memory — In-memory store for testing
32
+ attr_reader :backend
33
+
26
34
  # @return [String, nil] The Subflag API key
27
35
  attr_accessor :api_key
28
36
 
@@ -35,12 +43,42 @@ module Subflag
35
43
  # @return [Symbol] Log level for flag evaluations (:debug, :info, :warn)
36
44
  attr_accessor :log_level
37
45
 
46
+ # @return [Integer, ActiveSupport::Duration, nil] TTL for cross-request caching
47
+ # When set, prefetched flags are cached in Rails.cache for this duration.
48
+ # Set to nil to disable cross-request caching (default).
49
+ attr_accessor :cache_ttl
50
+
38
51
  def initialize
52
+ @backend = :subflag
39
53
  @api_key = nil
40
54
  @api_url = "https://api.subflag.com"
41
55
  @user_context_block = nil
42
56
  @logging_enabled = false
43
57
  @log_level = :debug
58
+ @cache_ttl = nil
59
+ end
60
+
61
+ # Set the backend with validation
62
+ #
63
+ # @param value [Symbol] The backend to use
64
+ # @raise [ArgumentError] If the backend is invalid
65
+ def backend=(value)
66
+ value = value.to_sym
67
+ unless VALID_BACKENDS.include?(value)
68
+ raise ArgumentError, "Invalid backend: #{value}. Use one of: #{VALID_BACKENDS.join(', ')}"
69
+ end
70
+
71
+ @backend = value
72
+ end
73
+
74
+ # Check if cross-request caching via Rails.cache is enabled
75
+ #
76
+ # @return [Boolean]
77
+ def rails_cache_enabled?
78
+ return false unless @cache_ttl && @cache_ttl.to_i > 0
79
+ return false unless defined?(::Rails.cache) && ::Rails.cache.present?
80
+
81
+ true
44
82
  end
45
83
 
46
84
  # 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
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # ActiveRecord model for storing feature flags in your database
6
+ #
7
+ # @example Create a boolean flag
8
+ # Subflag::Rails::Flag.create!(
9
+ # key: "new-checkout",
10
+ # value: "true",
11
+ # value_type: "boolean"
12
+ # )
13
+ #
14
+ # @example Create an integer flag
15
+ # Subflag::Rails::Flag.create!(
16
+ # key: "max-projects",
17
+ # value: "100",
18
+ # value_type: "integer"
19
+ # )
20
+ #
21
+ # @example Create a JSON object flag
22
+ # Subflag::Rails::Flag.create!(
23
+ # key: "feature-limits",
24
+ # value: '{"max_items": 10, "max_users": 5}',
25
+ # value_type: "object"
26
+ # )
27
+ #
28
+ class Flag < ::ActiveRecord::Base
29
+ self.table_name = "subflag_flags"
30
+
31
+ VALUE_TYPES = %w[boolean string integer float object].freeze
32
+
33
+ validates :key, presence: true,
34
+ uniqueness: true,
35
+ format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and dashes" }
36
+ validates :value_type, inclusion: { in: VALUE_TYPES }
37
+ validates :value, presence: true
38
+
39
+ scope :enabled, -> { where(enabled: true) }
40
+
41
+ # Get the flag value cast to its declared type
42
+ #
43
+ # @param expected_type [Symbol, String, nil] Override the value_type for casting
44
+ # @return [Object] The typed value
45
+ def typed_value(expected_type = nil)
46
+ type = expected_type&.to_s || value_type
47
+
48
+ case type.to_s
49
+ when "boolean"
50
+ ActiveModel::Type::Boolean.new.cast(value)
51
+ when "string"
52
+ value.to_s
53
+ when "integer"
54
+ value.to_i
55
+ when "float", "number"
56
+ value.to_f
57
+ when "object"
58
+ value.is_a?(Hash) ? value : JSON.parse(value)
59
+ else
60
+ value
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end