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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "targeting_engine"
4
+
5
+ module Subflag
6
+ module Rails
7
+ # Targeting module for evaluating rules that control who sees what flag values.
8
+ #
9
+ # Rules are stored as JSON in the database and evaluated at runtime.
10
+ # Use targeting to roll out features to internal teams before wider release.
11
+ #
12
+ # ## Rule Format
13
+ #
14
+ # Rules are an array of objects, each with a `value` and `conditions`:
15
+ #
16
+ # [
17
+ # {
18
+ # "value" => "true",
19
+ # "conditions" => {
20
+ # "type" => "OR",
21
+ # "conditions" => [
22
+ # { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
23
+ # { "attribute" => "role", "operator" => "IN", "value" => ["admin", "qa"] }
24
+ # ]
25
+ # }
26
+ # }
27
+ # ]
28
+ #
29
+ # ## Supported Operators
30
+ #
31
+ # - EQUALS, NOT_EQUALS - exact match
32
+ # - IN, NOT_IN - list membership
33
+ # - CONTAINS, NOT_CONTAINS - substring match
34
+ # - STARTS_WITH, ENDS_WITH - prefix/suffix match
35
+ # - GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL - numeric
36
+ # - MATCHES - regex match
37
+ #
38
+ # ## Evaluation
39
+ #
40
+ # Rules are evaluated in order. First match wins.
41
+ # If no rules match, the flag's default value is returned.
42
+ #
43
+ # TODO: Add admin UI for managing targeting rules
44
+ #
45
+ module Targeting
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Evaluates targeting rules against evaluation contexts.
6
+ #
7
+ # Rules are arrays of { "value" => X, "conditions" => {...} } hashes.
8
+ # First matching rule wins. Falls back to flag's default value if nothing matches.
9
+ #
10
+ # @example Rule structure
11
+ # [
12
+ # {
13
+ # "value" => "100",
14
+ # "conditions" => {
15
+ # "type" => "OR",
16
+ # "conditions" => [
17
+ # { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
18
+ # { "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer"] }
19
+ # ]
20
+ # }
21
+ # }
22
+ # ]
23
+ #
24
+ module TargetingEngine
25
+ OPERATORS = %w[
26
+ EQUALS NOT_EQUALS IN NOT_IN
27
+ CONTAINS NOT_CONTAINS STARTS_WITH ENDS_WITH
28
+ GREATER_THAN LESS_THAN GREATER_THAN_OR_EQUAL LESS_THAN_OR_EQUAL
29
+ MATCHES
30
+ ].freeze
31
+
32
+ class << self
33
+ # Evaluate targeting rules against a context
34
+ #
35
+ # @param rules [Array<Hash>, nil] Array of targeting rules with values
36
+ # @param context [Hash, nil] Evaluation context with attributes
37
+ # @return [String, nil] The matched rule's value, or nil if no match
38
+ def evaluate(rules, context)
39
+ return nil if rules.nil? || rules.empty?
40
+ return nil if context.nil? || context.empty?
41
+
42
+ # Normalize context keys to strings for comparison
43
+ normalized_context = normalize_context(context)
44
+
45
+ rules.each do |rule|
46
+ rule = rule.transform_keys(&:to_s)
47
+ conditions = rule["conditions"]
48
+ next unless conditions
49
+
50
+ if evaluate_rule(conditions, normalized_context)
51
+ return rule["value"]
52
+ end
53
+ end
54
+
55
+ nil # No rules matched
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_context(context)
61
+ context.transform_keys(&:to_s).transform_values do |v|
62
+ v.is_a?(Symbol) ? v.to_s : v
63
+ end
64
+ end
65
+
66
+ # Evaluate an AND/OR rule block
67
+ def evaluate_rule(rule, context)
68
+ rule = rule.transform_keys(&:to_s)
69
+ type = rule["type"]&.upcase || "AND"
70
+ conditions = rule["conditions"] || []
71
+
72
+ case type
73
+ when "AND"
74
+ conditions.all? { |c| evaluate_condition(c, context) }
75
+ when "OR"
76
+ conditions.any? { |c| evaluate_condition(c, context) }
77
+ else
78
+ false
79
+ end
80
+ end
81
+
82
+ # Evaluate a single condition
83
+ def evaluate_condition(condition, context)
84
+ condition = condition.transform_keys(&:to_s)
85
+ attribute = condition["attribute"]
86
+ operator = condition["operator"]&.upcase
87
+ expected = condition["value"]
88
+
89
+ return false unless attribute && operator
90
+
91
+ actual = context[attribute]
92
+ return false if actual.nil?
93
+
94
+ case operator
95
+ when "EQUALS"
96
+ compare_equals(actual, expected)
97
+ when "NOT_EQUALS"
98
+ !compare_equals(actual, expected)
99
+ when "IN"
100
+ compare_in(actual, expected)
101
+ when "NOT_IN"
102
+ !compare_in(actual, expected)
103
+ when "CONTAINS"
104
+ compare_contains(actual, expected)
105
+ when "NOT_CONTAINS"
106
+ !compare_contains(actual, expected)
107
+ when "STARTS_WITH"
108
+ compare_starts_with(actual, expected)
109
+ when "ENDS_WITH"
110
+ compare_ends_with(actual, expected)
111
+ when "GREATER_THAN"
112
+ compare_numeric(actual, expected) { |a, e| a > e }
113
+ when "LESS_THAN"
114
+ compare_numeric(actual, expected) { |a, e| a < e }
115
+ when "GREATER_THAN_OR_EQUAL"
116
+ compare_numeric(actual, expected) { |a, e| a >= e }
117
+ when "LESS_THAN_OR_EQUAL"
118
+ compare_numeric(actual, expected) { |a, e| a <= e }
119
+ when "MATCHES"
120
+ compare_matches(actual, expected)
121
+ else
122
+ false
123
+ end
124
+ end
125
+
126
+ def compare_equals(actual, expected)
127
+ normalize_value(actual) == normalize_value(expected)
128
+ end
129
+
130
+ def compare_in(actual, expected)
131
+ return false unless expected.is_a?(Array)
132
+
133
+ normalized_actual = normalize_value(actual)
134
+ expected.any? { |e| normalize_value(e) == normalized_actual }
135
+ end
136
+
137
+ def compare_contains(actual, expected)
138
+ actual.to_s.include?(expected.to_s)
139
+ end
140
+
141
+ def compare_starts_with(actual, expected)
142
+ actual.to_s.start_with?(expected.to_s)
143
+ end
144
+
145
+ def compare_ends_with(actual, expected)
146
+ actual.to_s.end_with?(expected.to_s)
147
+ end
148
+
149
+ def compare_numeric(actual, expected)
150
+ a = to_number(actual)
151
+ e = to_number(expected)
152
+ return false if a.nil? || e.nil?
153
+
154
+ yield(a, e)
155
+ end
156
+
157
+ def compare_matches(actual, expected)
158
+ Regexp.new(expected.to_s).match?(actual.to_s)
159
+ rescue RegexpError
160
+ false
161
+ end
162
+
163
+ def normalize_value(value)
164
+ case value
165
+ when Symbol then value.to_s
166
+ when TrueClass then true
167
+ when FalseClass then false
168
+ when Numeric then value
169
+ else value.to_s
170
+ end
171
+ end
172
+
173
+ def to_number(value)
174
+ case value
175
+ when Numeric then value
176
+ when String
177
+ if value.include?(".")
178
+ Float(value)
179
+ else
180
+ Integer(value)
181
+ end
182
+ else
183
+ nil
184
+ end
185
+ rescue ArgumentError, TypeError
186
+ nil
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/subflag/rails.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open_feature/sdk"
4
- require "subflag"
5
4
 
6
5
  require_relative "rails/version"
7
6
  require_relative "rails/configuration"
@@ -12,6 +11,7 @@ require_relative "rails/client"
12
11
  require_relative "rails/flag_accessor"
13
12
  require_relative "rails/helpers"
14
13
  require_relative "rails/railtie" if defined?(Rails::Railtie)
14
+ require_relative "rails/engine" if defined?(Rails::Engine)
15
15
 
16
16
  # Test helpers are loaded separately: require "subflag/rails/test_helpers"
17
17
 
@@ -27,14 +27,25 @@ module Subflag
27
27
 
28
28
  # Configure Subflag for Rails
29
29
  #
30
- # @example
30
+ # @example Using Subflag Cloud (SaaS)
31
31
  # Subflag::Rails.configure do |config|
32
+ # config.backend = :subflag
32
33
  # config.api_key = Rails.application.credentials.subflag_api_key
33
34
  # config.user_context do |user|
34
35
  # { targeting_key: user.id.to_s, email: user.email, plan: user.plan }
35
36
  # end
36
37
  # end
37
38
  #
39
+ # @example Using ActiveRecord (self-hosted)
40
+ # Subflag::Rails.configure do |config|
41
+ # config.backend = :active_record
42
+ # end
43
+ #
44
+ # @example Using Memory (testing)
45
+ # Subflag::Rails.configure do |config|
46
+ # config.backend = :memory
47
+ # end
48
+ #
38
49
  # @yield [Configuration]
39
50
  def configure
40
51
  yield(configuration)
@@ -48,6 +59,16 @@ module Subflag
48
59
  @client ||= Client.new
49
60
  end
50
61
 
62
+ # Access the current provider instance
63
+ #
64
+ # Useful for the Memory backend where you can set flags directly:
65
+ # Subflag::Rails.provider.set(:my_flag, true)
66
+ #
67
+ # @return [Object] The current OpenFeature provider
68
+ def provider
69
+ @provider
70
+ end
71
+
51
72
  # Prefetch all flags for a user/context in a single API call
52
73
  #
53
74
  # Call this early in a request to fetch all flags at once.
@@ -72,21 +93,53 @@ module Subflag
72
93
  def reset!
73
94
  @configuration = Configuration.new
74
95
  @client = nil
96
+ @provider = nil
75
97
  end
76
98
 
77
99
  private
78
100
 
79
101
  def setup_provider
80
- return unless configuration.api_key
102
+ @provider = build_provider
103
+ return unless @provider
81
104
 
82
- provider = ::Subflag::Provider.new(
105
+ OpenFeature::SDK.configure do |config|
106
+ config.set_provider(@provider)
107
+ end
108
+ end
109
+
110
+ def build_provider
111
+ case configuration.backend
112
+ when :subflag
113
+ build_subflag_provider
114
+ when :active_record
115
+ build_active_record_provider
116
+ when :memory
117
+ build_memory_provider
118
+ else
119
+ raise ArgumentError, "Unknown backend: #{configuration.backend}. Use :subflag, :active_record, or :memory"
120
+ end
121
+ end
122
+
123
+ def build_subflag_provider
124
+ return nil unless configuration.api_key
125
+
126
+ require_relative "rails/backends/subflag_provider"
127
+ Backends::SubflagProvider.new(
83
128
  api_key: configuration.api_key,
84
129
  api_url: configuration.api_url
85
130
  )
131
+ end
86
132
 
87
- OpenFeature::SDK.configure do |config|
88
- config.set_provider(provider)
89
- end
133
+ def build_active_record_provider
134
+ require_relative "rails/targeting"
135
+ require_relative "rails/backends/active_record_provider"
136
+ require_relative "rails/models/flag"
137
+ Backends::ActiveRecordProvider.new
138
+ end
139
+
140
+ def build_memory_provider
141
+ require_relative "rails/backends/memory_provider"
142
+ Backends::MemoryProvider.new
90
143
  end
91
144
  end
92
145
  end
metadata CHANGED
@@ -1,22 +1,22 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subflag-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subflag
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-08 00:00:00.000000000 Z
11
+ date: 2025-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: subflag-openfeature-provider
14
+ name: openfeature-sdk
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.3.1
19
+ version: '0.3'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
22
  version: '1.0'
@@ -26,7 +26,7 @@ dependencies:
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: 0.3.1
29
+ version: '0.3'
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '1.0'
@@ -170,9 +170,9 @@ dependencies:
170
170
  - - ">="
171
171
  - !ruby/object:Gem::Version
172
172
  version: '6.1'
173
- description: Rails integration for Subflag feature flags. Get typed values (boolean,
174
- string, integer, double, object) with user targeting. Includes generators, view
175
- helpers, and ActiveRecord context integration.
173
+ description: Feature flags for Rails with pluggable backends. Use Subflag Cloud (SaaS),
174
+ ActiveRecord (self-hosted), or Memory (testing). Get typed values (boolean, string,
175
+ integer, double, object) with the same API regardless of backend.
176
176
  email:
177
177
  - support@subflag.com
178
178
  executables: []
@@ -183,17 +183,25 @@ files:
183
183
  - LICENSE.txt
184
184
  - README.md
185
185
  - lib/generators/subflag/install_generator.rb
186
- - lib/generators/subflag/templates/initializer.rb
186
+ - lib/generators/subflag/templates/create_subflag_flags.rb.tt
187
+ - lib/generators/subflag/templates/initializer.rb.tt
187
188
  - lib/subflag-rails.rb
188
189
  - lib/subflag/rails.rb
190
+ - lib/subflag/rails/backends/active_record_provider.rb
191
+ - lib/subflag/rails/backends/memory_provider.rb
192
+ - lib/subflag/rails/backends/subflag_provider.rb
189
193
  - lib/subflag/rails/client.rb
190
194
  - lib/subflag/rails/configuration.rb
191
195
  - lib/subflag/rails/context_builder.rb
196
+ - lib/subflag/rails/engine.rb
192
197
  - lib/subflag/rails/evaluation_result.rb
193
198
  - lib/subflag/rails/flag_accessor.rb
194
199
  - lib/subflag/rails/helpers.rb
200
+ - lib/subflag/rails/models/flag.rb
195
201
  - lib/subflag/rails/railtie.rb
196
202
  - lib/subflag/rails/request_cache.rb
203
+ - lib/subflag/rails/targeting.rb
204
+ - lib/subflag/rails/targeting_engine.rb
197
205
  - lib/subflag/rails/test_helpers.rb
198
206
  - lib/subflag/rails/version.rb
199
207
  homepage: https://subflag.com