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,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
|
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
|
-
|
|
102
|
+
@provider = build_provider
|
|
103
|
+
return unless @provider
|
|
81
104
|
|
|
82
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2025-12-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
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
|
|
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
|
|
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:
|
|
174
|
-
|
|
175
|
-
|
|
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/
|
|
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
|