subflag-rails 0.3.0 → 0.5.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/README.md +287 -60
- data/lib/generators/subflag/install_generator.rb +105 -33
- data/lib/generators/subflag/templates/create_subflag_flags.rb.tt +22 -0
- data/lib/generators/subflag/templates/{initializer.rb → initializer.rb.tt} +16 -2
- data/lib/subflag/rails/backends/active_record_provider.rb +113 -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 +58 -12
- data/lib/subflag/rails/configuration.rb +45 -0
- data/lib/subflag/rails/engine.rb +34 -0
- data/lib/subflag/rails/models/flag.rb +154 -0
- data/lib/subflag/rails/targeting.rb +48 -0
- data/lib/subflag/rails/targeting_engine.rb +191 -0
- data/lib/subflag/rails/version.rb +1 -1
- data/lib/subflag/rails.rb +60 -7
- metadata +17 -9
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
module Backends
|
|
6
|
+
# Provider that reads flags from your Rails database with targeting support.
|
|
7
|
+
#
|
|
8
|
+
# Stores flags in a `subflag_flags` table with typed values and optional
|
|
9
|
+
# targeting rules for showing different values to different users.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic setup
|
|
12
|
+
# Subflag::Rails.configure do |config|
|
|
13
|
+
# config.backend = :active_record
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Create a simple flag
|
|
17
|
+
# Subflag::Rails::Flag.create!(
|
|
18
|
+
# key: "max-projects",
|
|
19
|
+
# value: "100",
|
|
20
|
+
# value_type: "integer"
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# @example Create a flag with targeting rules
|
|
24
|
+
# Subflag::Rails::Flag.create!(
|
|
25
|
+
# key: "new-dashboard",
|
|
26
|
+
# value: "false",
|
|
27
|
+
# value_type: "boolean",
|
|
28
|
+
# targeting_rules: [
|
|
29
|
+
# { "value" => "true", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" }] } }
|
|
30
|
+
# ]
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
class ActiveRecordProvider
|
|
34
|
+
def metadata
|
|
35
|
+
{ name: "Subflag ActiveRecord Provider" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def init; end
|
|
39
|
+
def shutdown; end
|
|
40
|
+
|
|
41
|
+
def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
|
|
42
|
+
resolve(flag_key, default_value, :boolean, evaluation_context)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
|
|
46
|
+
resolve(flag_key, default_value, :string, evaluation_context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
|
|
50
|
+
resolve(flag_key, default_value, :number, evaluation_context)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
|
|
54
|
+
resolve(flag_key, default_value, :integer, evaluation_context)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
|
|
58
|
+
resolve(flag_key, default_value, :float, evaluation_context)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
|
|
62
|
+
resolve(flag_key, default_value, :object, evaluation_context)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def resolve(flag_key, default_value, expected_type, evaluation_context)
|
|
68
|
+
flag = Subflag::Rails::Flag.find_by(key: flag_key)
|
|
69
|
+
|
|
70
|
+
unless flag&.enabled?
|
|
71
|
+
return resolution(default_value, reason: :default)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Convert OpenFeature context to hash for targeting evaluation
|
|
75
|
+
context = context_to_hash(evaluation_context)
|
|
76
|
+
value = flag.evaluate(context: context, expected_type: expected_type)
|
|
77
|
+
|
|
78
|
+
# Determine if targeting matched
|
|
79
|
+
reason = flag.targeting_rules.present? && context.present? ? :targeting_match : :static
|
|
80
|
+
resolution(value, reason: reason, variant: "default")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def context_to_hash(evaluation_context)
|
|
84
|
+
return nil if evaluation_context.nil?
|
|
85
|
+
|
|
86
|
+
# OpenFeature::SDK::EvaluationContext stores fields as instance variables
|
|
87
|
+
# We need to extract them into a hash for our targeting engine
|
|
88
|
+
if evaluation_context.respond_to?(:to_h)
|
|
89
|
+
evaluation_context.to_h
|
|
90
|
+
elsif evaluation_context.respond_to?(:fields)
|
|
91
|
+
evaluation_context.fields
|
|
92
|
+
else
|
|
93
|
+
# Fallback: extract instance variables
|
|
94
|
+
hash = {}
|
|
95
|
+
evaluation_context.instance_variables.each do |var|
|
|
96
|
+
key = var.to_s.delete("@")
|
|
97
|
+
hash[key] = evaluation_context.instance_variable_get(var)
|
|
98
|
+
end
|
|
99
|
+
hash
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolution(value, reason:, variant: nil)
|
|
104
|
+
OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
105
|
+
value: value,
|
|
106
|
+
reason: reason,
|
|
107
|
+
variant: variant
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -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,6 +2,10 @@
|
|
|
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.
|
|
@@ -10,8 +14,11 @@ module Subflag
|
|
|
10
14
|
class Client
|
|
11
15
|
# Prefetch all flags for a user/context
|
|
12
16
|
#
|
|
13
|
-
# Fetches all flags
|
|
14
|
-
#
|
|
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)
|
|
15
22
|
#
|
|
16
23
|
# @param user [Object, nil] The user object for targeting
|
|
17
24
|
# @param context [Hash, nil] Additional context attributes
|
|
@@ -23,16 +30,17 @@ module Subflag
|
|
|
23
30
|
# subflag_enabled?(:new_feature)
|
|
24
31
|
#
|
|
25
32
|
def prefetch_all(user: nil, context: nil)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
[]
|
|
32
43
|
end
|
|
33
|
-
|
|
34
|
-
# Otherwise fetch directly from API (per-request cache only)
|
|
35
|
-
prefetch_from_api(ctx, context_hash)
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
# Check if a boolean flag is enabled
|
|
@@ -121,6 +129,20 @@ module Subflag
|
|
|
121
129
|
|
|
122
130
|
private
|
|
123
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
|
+
|
|
124
146
|
# Fetch flags from API and populate RequestCache
|
|
125
147
|
def prefetch_from_api(ctx, context_hash)
|
|
126
148
|
subflag_context = build_subflag_context(ctx)
|
|
@@ -151,7 +173,7 @@ module Subflag
|
|
|
151
173
|
cached_data
|
|
152
174
|
end
|
|
153
175
|
|
|
154
|
-
# Populate RequestCache from cached hash data
|
|
176
|
+
# Populate RequestCache from cached hash data (Subflag API)
|
|
155
177
|
def populate_request_cache_from_data(data_array, context_hash)
|
|
156
178
|
return unless RequestCache.enabled?
|
|
157
179
|
|
|
@@ -161,6 +183,30 @@ module Subflag
|
|
|
161
183
|
end
|
|
162
184
|
end
|
|
163
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
|
+
|
|
164
210
|
def build_cache_key(flag_key, ctx, type)
|
|
165
211
|
context_hash = ctx ? ctx.hash : "no_context"
|
|
166
212
|
"subflag:#{flag_key}:#{context_hash}:#{type}"
|
|
@@ -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
|
|
|
@@ -40,13 +48,31 @@ module Subflag
|
|
|
40
48
|
# Set to nil to disable cross-request caching (default).
|
|
41
49
|
attr_accessor :cache_ttl
|
|
42
50
|
|
|
51
|
+
# @return [Proc, nil] Admin authentication callback for the admin UI
|
|
52
|
+
attr_reader :admin_auth_callback
|
|
53
|
+
|
|
43
54
|
def initialize
|
|
55
|
+
@backend = :subflag
|
|
44
56
|
@api_key = nil
|
|
45
57
|
@api_url = "https://api.subflag.com"
|
|
46
58
|
@user_context_block = nil
|
|
47
59
|
@logging_enabled = false
|
|
48
60
|
@log_level = :debug
|
|
49
61
|
@cache_ttl = nil
|
|
62
|
+
@admin_auth_callback = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Set the backend with validation
|
|
66
|
+
#
|
|
67
|
+
# @param value [Symbol] The backend to use
|
|
68
|
+
# @raise [ArgumentError] If the backend is invalid
|
|
69
|
+
def backend=(value)
|
|
70
|
+
value = value.to_sym
|
|
71
|
+
unless VALID_BACKENDS.include?(value)
|
|
72
|
+
raise ArgumentError, "Invalid backend: #{value}. Use one of: #{VALID_BACKENDS.join(', ')}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@backend = value
|
|
50
76
|
end
|
|
51
77
|
|
|
52
78
|
# Check if cross-request caching via Rails.cache is enabled
|
|
@@ -94,6 +120,25 @@ module Subflag
|
|
|
94
120
|
|
|
95
121
|
@user_context_block.call(user)
|
|
96
122
|
end
|
|
123
|
+
|
|
124
|
+
# Configure authentication for the admin UI
|
|
125
|
+
#
|
|
126
|
+
# @yield [controller] Block called before each admin action
|
|
127
|
+
# @yieldparam controller [ActionController::Base] The controller instance
|
|
128
|
+
#
|
|
129
|
+
# @example Require admin role
|
|
130
|
+
# config.admin_auth do
|
|
131
|
+
# redirect_to main_app.root_path unless current_user&.admin?
|
|
132
|
+
# end
|
|
133
|
+
#
|
|
134
|
+
# @example Use Devise authenticate
|
|
135
|
+
# config.admin_auth do
|
|
136
|
+
# authenticate_user!
|
|
137
|
+
# end
|
|
138
|
+
def admin_auth(&block)
|
|
139
|
+
@admin_auth_callback = block if block_given?
|
|
140
|
+
@admin_auth_callback
|
|
141
|
+
end
|
|
97
142
|
end
|
|
98
143
|
end
|
|
99
144
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Mountable engine for the Subflag admin UI.
|
|
6
|
+
#
|
|
7
|
+
# Mount in your routes to get a web UI for managing flags:
|
|
8
|
+
#
|
|
9
|
+
# # config/routes.rb
|
|
10
|
+
# mount Subflag::Rails::Engine => "/subflag"
|
|
11
|
+
#
|
|
12
|
+
# The engine provides:
|
|
13
|
+
# - Flag CRUD (list, create, edit, delete)
|
|
14
|
+
# - Targeting rule builder
|
|
15
|
+
# - Rule testing interface
|
|
16
|
+
#
|
|
17
|
+
# Security: Configure authentication in an initializer:
|
|
18
|
+
#
|
|
19
|
+
# Subflag::Rails.configure do |config|
|
|
20
|
+
# config.admin_auth do |controller|
|
|
21
|
+
# controller.authenticate_admin! # Your auth method
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class Engine < ::Rails::Engine
|
|
26
|
+
isolate_namespace Subflag::Rails
|
|
27
|
+
|
|
28
|
+
# Load engine routes
|
|
29
|
+
initializer "subflag.routes" do |app|
|
|
30
|
+
# Routes are loaded automatically from config/routes.rb
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# ActiveRecord model for storing feature flags in your database.
|
|
6
|
+
#
|
|
7
|
+
# Supports targeting rules to show different values to different users.
|
|
8
|
+
# Perfect for internal testing before wider rollout.
|
|
9
|
+
#
|
|
10
|
+
# @example Simple flag (everyone gets the same value)
|
|
11
|
+
# Subflag::Rails::Flag.create!(
|
|
12
|
+
# key: "new-checkout",
|
|
13
|
+
# value: "true",
|
|
14
|
+
# value_type: "boolean"
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example Flag with targeting rules (internal team gets different value)
|
|
18
|
+
# Subflag::Rails::Flag.create!(
|
|
19
|
+
# key: "new-dashboard",
|
|
20
|
+
# value: "false",
|
|
21
|
+
# value_type: "boolean",
|
|
22
|
+
# targeting_rules: [
|
|
23
|
+
# {
|
|
24
|
+
# "value" => "true",
|
|
25
|
+
# "conditions" => {
|
|
26
|
+
# "type" => "OR",
|
|
27
|
+
# "conditions" => [
|
|
28
|
+
# { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
|
|
29
|
+
# { "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer", "qa"] }
|
|
30
|
+
# ]
|
|
31
|
+
# }
|
|
32
|
+
# }
|
|
33
|
+
# ]
|
|
34
|
+
# )
|
|
35
|
+
#
|
|
36
|
+
# @example Progressive rollout (first match wins)
|
|
37
|
+
# Subflag::Rails::Flag.create!(
|
|
38
|
+
# key: "max-projects",
|
|
39
|
+
# value: "5",
|
|
40
|
+
# value_type: "integer",
|
|
41
|
+
# targeting_rules: [
|
|
42
|
+
# { "value" => "1000", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }] } },
|
|
43
|
+
# { "value" => "100", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" }] } },
|
|
44
|
+
# { "value" => "25", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "plan", "operator" => "EQUALS", "value" => "pro" }] } }
|
|
45
|
+
# ]
|
|
46
|
+
# )
|
|
47
|
+
#
|
|
48
|
+
class Flag < ::ActiveRecord::Base
|
|
49
|
+
self.table_name = "subflag_flags"
|
|
50
|
+
|
|
51
|
+
VALUE_TYPES = %w[boolean string integer float object].freeze
|
|
52
|
+
|
|
53
|
+
validates :key, presence: true,
|
|
54
|
+
uniqueness: true,
|
|
55
|
+
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and dashes" }
|
|
56
|
+
validates :value_type, inclusion: { in: VALUE_TYPES }
|
|
57
|
+
validates :value, presence: true
|
|
58
|
+
validate :validate_targeting_rules
|
|
59
|
+
|
|
60
|
+
scope :enabled, -> { where(enabled: true) }
|
|
61
|
+
|
|
62
|
+
# Evaluate the flag for a given context
|
|
63
|
+
#
|
|
64
|
+
# Returns the matched rule's value if context matches targeting rules,
|
|
65
|
+
# otherwise returns the default value.
|
|
66
|
+
#
|
|
67
|
+
# @param context [Hash, nil] Evaluation context with user attributes
|
|
68
|
+
# @param expected_type [Symbol, String, nil] Override the value_type for casting
|
|
69
|
+
# @return [Object] The evaluated value, cast to the appropriate type
|
|
70
|
+
def evaluate(context: nil, expected_type: nil)
|
|
71
|
+
rules = parsed_targeting_rules
|
|
72
|
+
raw_value = if rules.present? && context.present?
|
|
73
|
+
matched = TargetingEngine.evaluate(rules, context)
|
|
74
|
+
matched || value
|
|
75
|
+
else
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
cast_value(raw_value, expected_type)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get the flag's default value cast to its declared type (ignores targeting)
|
|
83
|
+
#
|
|
84
|
+
# @param expected_type [Symbol, String, nil] Override the value_type for casting
|
|
85
|
+
# @return [Object] The typed value
|
|
86
|
+
def typed_value(expected_type = nil)
|
|
87
|
+
cast_value(value, expected_type)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def cast_value(raw_value, expected_type = nil)
|
|
93
|
+
type = expected_type&.to_s || value_type
|
|
94
|
+
|
|
95
|
+
case type.to_s
|
|
96
|
+
when "boolean"
|
|
97
|
+
ActiveModel::Type::Boolean.new.cast(raw_value)
|
|
98
|
+
when "string"
|
|
99
|
+
raw_value.to_s
|
|
100
|
+
when "integer"
|
|
101
|
+
raw_value.to_i
|
|
102
|
+
when "float", "number"
|
|
103
|
+
raw_value.to_f
|
|
104
|
+
when "object"
|
|
105
|
+
raw_value.is_a?(Hash) ? raw_value : JSON.parse(raw_value)
|
|
106
|
+
else
|
|
107
|
+
raw_value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parsed_targeting_rules
|
|
112
|
+
return nil if targeting_rules.blank?
|
|
113
|
+
|
|
114
|
+
case targeting_rules
|
|
115
|
+
when String
|
|
116
|
+
JSON.parse(targeting_rules)
|
|
117
|
+
when Array
|
|
118
|
+
targeting_rules
|
|
119
|
+
else
|
|
120
|
+
targeting_rules
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_targeting_rules
|
|
125
|
+
return if targeting_rules.blank?
|
|
126
|
+
|
|
127
|
+
rules = case targeting_rules
|
|
128
|
+
when Array then targeting_rules
|
|
129
|
+
when String
|
|
130
|
+
begin
|
|
131
|
+
JSON.parse(targeting_rules)
|
|
132
|
+
rescue JSON::ParserError
|
|
133
|
+
errors.add(:targeting_rules, "must be valid JSON")
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
errors.add(:targeting_rules, "must be an array of rules")
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
rules.each_with_index do |rule, index|
|
|
142
|
+
unless rule.is_a?(Hash)
|
|
143
|
+
errors.add(:targeting_rules, "rule #{index} must be a hash")
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
rule = rule.transform_keys(&:to_s)
|
|
148
|
+
errors.add(:targeting_rules, "rule #{index} must have a 'value' key") unless rule.key?("value")
|
|
149
|
+
errors.add(:targeting_rules, "rule #{index} must have a 'conditions' key") unless rule.key?("conditions")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|