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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +201 -37
- data/lib/generators/subflag/install_generator.rb +97 -33
- data/lib/generators/subflag/templates/create_subflag_flags.rb.tt +17 -0
- data/lib/generators/subflag/templates/{initializer.rb → initializer.rb.tt} +16 -2
- data/lib/subflag/rails/backends/active_record_provider.rb +82 -0
- data/lib/subflag/rails/backends/memory_provider.rb +104 -0
- data/lib/subflag/rails/backends/subflag_provider.rb +85 -0
- data/lib/subflag/rails/client.rb +213 -0
- data/lib/subflag/rails/configuration.rb +38 -0
- data/lib/subflag/rails/evaluation_result.rb +16 -0
- data/lib/subflag/rails/helpers.rb +30 -0
- data/lib/subflag/rails/models/flag.rb +65 -0
- data/lib/subflag/rails/version.rb +1 -1
- data/lib/subflag/rails.rb +93 -7
- metadata +14 -9
|
@@ -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
|
data/lib/subflag/rails/client.rb
CHANGED
|
@@ -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
|