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.
@@ -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
@@ -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 in a single API call and caches them.
14
- # Subsequent flag lookups will use the cached values.
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
- 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)
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