subflag-rails 0.4.0 → 0.5.1
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 +40 -0
- data/README.md +202 -39
- data/app/controllers/subflag/rails/application_controller.rb +22 -0
- data/app/controllers/subflag/rails/flags_controller.rb +85 -0
- data/app/views/layouts/subflag/rails/application.html.erb +72 -0
- data/app/views/subflag/rails/flags/_form.html.erb +45 -0
- data/app/views/subflag/rails/flags/edit.html.erb +241 -0
- data/app/views/subflag/rails/flags/index.html.erb +50 -0
- data/app/views/subflag/rails/flags/new.html.erb +5 -0
- data/config/routes.rb +12 -0
- data/lib/generators/subflag/install_generator.rb +9 -1
- data/lib/generators/subflag/templates/create_subflag_flags.rb.tt +5 -0
- data/lib/subflag/rails/backends/active_record_provider.rb +49 -18
- data/lib/subflag/rails/configuration.rb +23 -0
- data/lib/subflag/rails/engine.rb +34 -0
- data/lib/subflag/rails/models/flag.rb +106 -17
- 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 +2 -0
- metadata +14 -3
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-10">
|
|
2
|
+
<h2 style="font-size: 20px;">Edit Flag: <span class="mono"><%= @flag.key %></span></h2>
|
|
3
|
+
<a href="<%= flags_path %>" class="btn">Back to Flags</a>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="card">
|
|
7
|
+
<h2>Basic Settings</h2>
|
|
8
|
+
<%= render "form", flag: @flag %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="card">
|
|
12
|
+
<h2>Targeting Rules</h2>
|
|
13
|
+
<p class="text-muted mb-10">Rules are evaluated in order. First match wins. If no rules match, the default value is used.</p>
|
|
14
|
+
|
|
15
|
+
<div id="rules-container">
|
|
16
|
+
<!-- Rules rendered by JS -->
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<button type="button" class="btn mt-10" onclick="addRule()">+ Add Rule</button>
|
|
20
|
+
|
|
21
|
+
<input type="hidden" name="targeting_rules_json" id="targeting-rules-json" value="<%= @flag.targeting_rules.to_json %>">
|
|
22
|
+
|
|
23
|
+
<div class="mt-10">
|
|
24
|
+
<button type="button" class="btn btn-primary" onclick="saveRules()">Save Rules</button>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="card">
|
|
29
|
+
<h2>Test Rules</h2>
|
|
30
|
+
<p class="text-muted mb-10">Enter a context (JSON) to test which value would be returned.</p>
|
|
31
|
+
|
|
32
|
+
<%= form_with url: test_flag_path(@flag), method: :post, local: true do |f| %>
|
|
33
|
+
<div class="form-group">
|
|
34
|
+
<%= f.label :context, "Test Context (JSON)" %>
|
|
35
|
+
<%= f.text_area :context, rows: 4, placeholder: '{"email": "test@company.com", "role": "admin"}', value: @test_context&.to_json, class: "mono" %>
|
|
36
|
+
</div>
|
|
37
|
+
<%= f.submit "Test", class: "btn" %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<% if defined?(@test_result) && @test_result %>
|
|
41
|
+
<div class="mt-10" style="padding: 15px; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 4px;">
|
|
42
|
+
<strong>Result:</strong> <span class="mono"><%= @test_result.inspect %></span>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.rule { border: 1px solid #ddd; border-radius: 4px; padding: 15px; margin-bottom: 10px; background: #fafafa; }
|
|
49
|
+
.rule-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
|
50
|
+
.rule-value { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
|
|
51
|
+
.rule-value input { width: 200px; }
|
|
52
|
+
.rule-logic { margin-bottom: 10px; }
|
|
53
|
+
.rule-logic label { margin-right: 15px; }
|
|
54
|
+
.condition { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
|
55
|
+
.condition select, .condition input { padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; }
|
|
56
|
+
.condition input[type="text"] { width: 180px; }
|
|
57
|
+
.remove-btn { background: none; border: none; color: #dc3545; cursor: pointer; font-size: 18px; padding: 0 5px; }
|
|
58
|
+
.remove-btn:hover { color: #c82333; }
|
|
59
|
+
</style>
|
|
60
|
+
|
|
61
|
+
<script>
|
|
62
|
+
const OPERATORS = [
|
|
63
|
+
{ value: "EQUALS", label: "equals" },
|
|
64
|
+
{ value: "NOT_EQUALS", label: "not equals" },
|
|
65
|
+
{ value: "IN", label: "in (comma-separated)" },
|
|
66
|
+
{ value: "NOT_IN", label: "not in" },
|
|
67
|
+
{ value: "CONTAINS", label: "contains" },
|
|
68
|
+
{ value: "STARTS_WITH", label: "starts with" },
|
|
69
|
+
{ value: "ENDS_WITH", label: "ends with" },
|
|
70
|
+
{ value: "GREATER_THAN", label: ">" },
|
|
71
|
+
{ value: "LESS_THAN", label: "<" },
|
|
72
|
+
{ value: "MATCHES", label: "matches (regex)" }
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
let rules = [];
|
|
76
|
+
|
|
77
|
+
function init() {
|
|
78
|
+
const saved = document.getElementById("targeting-rules-json").value;
|
|
79
|
+
if (saved) {
|
|
80
|
+
try {
|
|
81
|
+
rules = JSON.parse(saved) || [];
|
|
82
|
+
} catch (e) {
|
|
83
|
+
rules = [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
renderRules();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderRules() {
|
|
90
|
+
const container = document.getElementById("rules-container");
|
|
91
|
+
container.innerHTML = rules.map((rule, ruleIndex) => renderRule(rule, ruleIndex)).join("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderRule(rule, ruleIndex) {
|
|
95
|
+
const conditions = rule.conditions?.conditions || [];
|
|
96
|
+
const logicType = rule.conditions?.type || "AND";
|
|
97
|
+
|
|
98
|
+
return `
|
|
99
|
+
<div class="rule" data-rule-index="${ruleIndex}">
|
|
100
|
+
<div class="rule-header">
|
|
101
|
+
<strong>Rule ${ruleIndex + 1}</strong>
|
|
102
|
+
<button type="button" class="remove-btn" onclick="removeRule(${ruleIndex})">×</button>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="rule-value">
|
|
105
|
+
<label>Return value:</label>
|
|
106
|
+
<input type="text" value="${escapeHtml(rule.value || '')}" onchange="updateRuleValue(${ruleIndex}, this.value)" placeholder="value when matched">
|
|
107
|
+
</div>
|
|
108
|
+
<div class="rule-logic">
|
|
109
|
+
<label><input type="radio" name="logic-${ruleIndex}" value="AND" ${logicType === 'AND' ? 'checked' : ''} onchange="updateLogic(${ruleIndex}, 'AND')"> ALL match</label>
|
|
110
|
+
<label><input type="radio" name="logic-${ruleIndex}" value="OR" ${logicType === 'OR' ? 'checked' : ''} onchange="updateLogic(${ruleIndex}, 'OR')"> ANY match</label>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="conditions" id="conditions-${ruleIndex}">
|
|
113
|
+
${conditions.map((c, cIndex) => renderCondition(c, ruleIndex, cIndex)).join("")}
|
|
114
|
+
</div>
|
|
115
|
+
<button type="button" class="btn btn-sm" onclick="addCondition(${ruleIndex})">+ Add Condition</button>
|
|
116
|
+
</div>
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderCondition(condition, ruleIndex, condIndex) {
|
|
121
|
+
const operatorOptions = OPERATORS.map(op =>
|
|
122
|
+
`<option value="${op.value}" ${condition.operator === op.value ? 'selected' : ''}>${op.label}</option>`
|
|
123
|
+
).join("");
|
|
124
|
+
|
|
125
|
+
const valueDisplay = Array.isArray(condition.value) ? condition.value.join(", ") : (condition.value || "");
|
|
126
|
+
|
|
127
|
+
return `
|
|
128
|
+
<div class="condition">
|
|
129
|
+
<input type="text" placeholder="attribute" value="${escapeHtml(condition.attribute || '')}" onchange="updateCondition(${ruleIndex}, ${condIndex}, 'attribute', this.value)">
|
|
130
|
+
<select onchange="updateCondition(${ruleIndex}, ${condIndex}, 'operator', this.value)">
|
|
131
|
+
${operatorOptions}
|
|
132
|
+
</select>
|
|
133
|
+
<input type="text" placeholder="value" value="${escapeHtml(valueDisplay)}" onchange="updateCondition(${ruleIndex}, ${condIndex}, 'value', this.value)">
|
|
134
|
+
<button type="button" class="remove-btn" onclick="removeCondition(${ruleIndex}, ${condIndex})">×</button>
|
|
135
|
+
</div>
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function addRule() {
|
|
140
|
+
rules.push({
|
|
141
|
+
value: "",
|
|
142
|
+
conditions: { type: "AND", conditions: [{ attribute: "", operator: "EQUALS", value: "" }] }
|
|
143
|
+
});
|
|
144
|
+
renderRules();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function removeRule(index) {
|
|
148
|
+
rules.splice(index, 1);
|
|
149
|
+
renderRules();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function updateRuleValue(ruleIndex, value) {
|
|
153
|
+
rules[ruleIndex].value = value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function updateLogic(ruleIndex, type) {
|
|
157
|
+
rules[ruleIndex].conditions.type = type;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function addCondition(ruleIndex) {
|
|
161
|
+
if (!rules[ruleIndex].conditions.conditions) {
|
|
162
|
+
rules[ruleIndex].conditions.conditions = [];
|
|
163
|
+
}
|
|
164
|
+
rules[ruleIndex].conditions.conditions.push({ attribute: "", operator: "EQUALS", value: "" });
|
|
165
|
+
renderRules();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function removeCondition(ruleIndex, condIndex) {
|
|
169
|
+
rules[ruleIndex].conditions.conditions.splice(condIndex, 1);
|
|
170
|
+
renderRules();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function updateCondition(ruleIndex, condIndex, field, value) {
|
|
174
|
+
const cond = rules[ruleIndex].conditions.conditions[condIndex];
|
|
175
|
+
if (field === "value" && (cond.operator === "IN" || cond.operator === "NOT_IN")) {
|
|
176
|
+
cond.value = value.split(",").map(v => v.trim());
|
|
177
|
+
} else {
|
|
178
|
+
cond[field] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function saveRules() {
|
|
183
|
+
const form = document.createElement("form");
|
|
184
|
+
form.method = "POST";
|
|
185
|
+
form.action = "<%= flag_path(@flag) %>";
|
|
186
|
+
|
|
187
|
+
const methodInput = document.createElement("input");
|
|
188
|
+
methodInput.type = "hidden";
|
|
189
|
+
methodInput.name = "_method";
|
|
190
|
+
methodInput.value = "PATCH";
|
|
191
|
+
form.appendChild(methodInput);
|
|
192
|
+
|
|
193
|
+
const csrfInput = document.createElement("input");
|
|
194
|
+
csrfInput.type = "hidden";
|
|
195
|
+
csrfInput.name = "<%= request_forgery_protection_token %>";
|
|
196
|
+
csrfInput.value = "<%= form_authenticity_token %>";
|
|
197
|
+
form.appendChild(csrfInput);
|
|
198
|
+
|
|
199
|
+
// Send all flag params to preserve other fields
|
|
200
|
+
const keyInput = document.createElement("input");
|
|
201
|
+
keyInput.type = "hidden";
|
|
202
|
+
keyInput.name = "flag[key]";
|
|
203
|
+
keyInput.value = "<%= @flag.key %>";
|
|
204
|
+
form.appendChild(keyInput);
|
|
205
|
+
|
|
206
|
+
const valueInput = document.createElement("input");
|
|
207
|
+
valueInput.type = "hidden";
|
|
208
|
+
valueInput.name = "flag[value]";
|
|
209
|
+
valueInput.value = "<%= @flag.value %>";
|
|
210
|
+
form.appendChild(valueInput);
|
|
211
|
+
|
|
212
|
+
const typeInput = document.createElement("input");
|
|
213
|
+
typeInput.type = "hidden";
|
|
214
|
+
typeInput.name = "flag[value_type]";
|
|
215
|
+
typeInput.value = "<%= @flag.value_type %>";
|
|
216
|
+
form.appendChild(typeInput);
|
|
217
|
+
|
|
218
|
+
const enabledInput = document.createElement("input");
|
|
219
|
+
enabledInput.type = "hidden";
|
|
220
|
+
enabledInput.name = "flag[enabled]";
|
|
221
|
+
enabledInput.value = "<%= @flag.enabled? %>";
|
|
222
|
+
form.appendChild(enabledInput);
|
|
223
|
+
|
|
224
|
+
const rulesInput = document.createElement("input");
|
|
225
|
+
rulesInput.type = "hidden";
|
|
226
|
+
rulesInput.name = "flag[targeting_rules]";
|
|
227
|
+
rulesInput.value = JSON.stringify(rules);
|
|
228
|
+
form.appendChild(rulesInput);
|
|
229
|
+
|
|
230
|
+
document.body.appendChild(form);
|
|
231
|
+
form.submit();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function escapeHtml(str) {
|
|
235
|
+
const div = document.createElement("div");
|
|
236
|
+
div.textContent = str;
|
|
237
|
+
return div.innerHTML;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
241
|
+
</script>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-10">
|
|
2
|
+
<h2 style="font-size: 20px;">Feature Flags</h2>
|
|
3
|
+
<a href="<%= new_flag_path %>" class="btn btn-primary">New Flag</a>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="card">
|
|
7
|
+
<% if @flags.any? %>
|
|
8
|
+
<table>
|
|
9
|
+
<thead>
|
|
10
|
+
<tr>
|
|
11
|
+
<th>Key</th>
|
|
12
|
+
<th>Type</th>
|
|
13
|
+
<th>Default Value</th>
|
|
14
|
+
<th>Status</th>
|
|
15
|
+
<th>Rules</th>
|
|
16
|
+
<th></th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<% @flags.each do |flag| %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td class="mono"><%= flag.key %></td>
|
|
23
|
+
<td><%= flag.value_type %></td>
|
|
24
|
+
<td class="mono"><%= truncate(flag.value.to_s, length: 30) %></td>
|
|
25
|
+
<td>
|
|
26
|
+
<% if flag.enabled? %>
|
|
27
|
+
<span class="badge badge-success">enabled</span>
|
|
28
|
+
<% else %>
|
|
29
|
+
<span class="badge badge-secondary">disabled</span>
|
|
30
|
+
<% end %>
|
|
31
|
+
</td>
|
|
32
|
+
<td>
|
|
33
|
+
<% rules_count = flag.targeting_rules&.size || 0 %>
|
|
34
|
+
<span class="text-muted"><%= rules_count %> rule<%= rules_count == 1 ? '' : 's' %></span>
|
|
35
|
+
</td>
|
|
36
|
+
<td>
|
|
37
|
+
<div class="actions">
|
|
38
|
+
<a href="<%= edit_flag_path(flag) %>" class="btn btn-sm">Edit</a>
|
|
39
|
+
<%= button_to flag.enabled? ? "Disable" : "Enable", toggle_flag_path(flag), method: :post, class: "btn btn-sm" %>
|
|
40
|
+
<%= button_to "Delete", flag_path(flag), method: :delete, class: "btn btn-sm btn-danger", data: { confirm: "Delete flag '#{flag.key}'?" } %>
|
|
41
|
+
</div>
|
|
42
|
+
</td>
|
|
43
|
+
</tr>
|
|
44
|
+
<% end %>
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
<% else %>
|
|
48
|
+
<p class="text-muted">No flags yet. <a href="<%= new_flag_path %>">Create your first flag</a>.</p>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -30,11 +30,19 @@ module Subflag
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Helper for templates to detect PostgreSQL adapter
|
|
34
|
+
def postgresql?
|
|
35
|
+
return false unless defined?(::ActiveRecord::Base)
|
|
36
|
+
|
|
37
|
+
adapter = ::ActiveRecord::Base.connection_db_config.adapter.to_s rescue nil
|
|
38
|
+
adapter&.include?("postgresql") || adapter&.include?("postgis")
|
|
39
|
+
end
|
|
40
|
+
|
|
33
41
|
def create_initializer
|
|
34
42
|
template "initializer.rb.tt", "config/initializers/subflag.rb"
|
|
35
43
|
end
|
|
36
44
|
|
|
37
|
-
def
|
|
45
|
+
def create_flags_migration
|
|
38
46
|
return unless options[:backend] == "active_record"
|
|
39
47
|
|
|
40
48
|
migration_template "create_subflag_flags.rb.tt",
|
|
@@ -9,6 +9,11 @@ class CreateSubflagFlags < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
|
|
|
9
9
|
t.boolean :enabled, null: false, default: true
|
|
10
10
|
t.text :description
|
|
11
11
|
|
|
12
|
+
# Targeting rules for showing different values to different users
|
|
13
|
+
# Stores an array of { value, conditions } rules as JSON
|
|
14
|
+
# First matching rule wins; falls back to `value` if no match
|
|
15
|
+
t.<%= postgresql? ? 'jsonb' : 'json' %> :targeting_rules
|
|
16
|
+
|
|
12
17
|
t.timestamps
|
|
13
18
|
end
|
|
14
19
|
|
|
@@ -3,26 +3,32 @@
|
|
|
3
3
|
module Subflag
|
|
4
4
|
module Rails
|
|
5
5
|
module Backends
|
|
6
|
-
# Provider that reads flags from your Rails database
|
|
6
|
+
# Provider that reads flags from your Rails database with targeting support.
|
|
7
7
|
#
|
|
8
|
-
# Stores flags in a `subflag_flags` table with typed values
|
|
9
|
-
#
|
|
8
|
+
# Stores flags in a `subflag_flags` table with typed values and optional
|
|
9
|
+
# targeting rules for showing different values to different users.
|
|
10
10
|
#
|
|
11
|
-
# @example
|
|
11
|
+
# @example Basic setup
|
|
12
12
|
# Subflag::Rails.configure do |config|
|
|
13
13
|
# config.backend = :active_record
|
|
14
14
|
# end
|
|
15
15
|
#
|
|
16
|
-
#
|
|
16
|
+
# @example Create a simple flag
|
|
17
17
|
# Subflag::Rails::Flag.create!(
|
|
18
18
|
# key: "max-projects",
|
|
19
19
|
# value: "100",
|
|
20
|
-
# value_type: "integer"
|
|
21
|
-
# enabled: true
|
|
20
|
+
# value_type: "integer"
|
|
22
21
|
# )
|
|
23
22
|
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
+
# )
|
|
26
32
|
#
|
|
27
33
|
class ActiveRecordProvider
|
|
28
34
|
def metadata
|
|
@@ -33,40 +39,65 @@ module Subflag
|
|
|
33
39
|
def shutdown; end
|
|
34
40
|
|
|
35
41
|
def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
|
|
36
|
-
resolve(flag_key, default_value, :boolean)
|
|
42
|
+
resolve(flag_key, default_value, :boolean, evaluation_context)
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
|
|
40
|
-
resolve(flag_key, default_value, :string)
|
|
46
|
+
resolve(flag_key, default_value, :string, evaluation_context)
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
|
|
44
|
-
resolve(flag_key, default_value, :number)
|
|
50
|
+
resolve(flag_key, default_value, :number, evaluation_context)
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
|
|
48
|
-
resolve(flag_key, default_value, :integer)
|
|
54
|
+
resolve(flag_key, default_value, :integer, evaluation_context)
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
|
|
52
|
-
resolve(flag_key, default_value, :float)
|
|
58
|
+
resolve(flag_key, default_value, :float, evaluation_context)
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
|
|
56
|
-
resolve(flag_key, default_value, :object)
|
|
62
|
+
resolve(flag_key, default_value, :object, evaluation_context)
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
private
|
|
60
66
|
|
|
61
|
-
def resolve(flag_key, default_value, expected_type)
|
|
67
|
+
def resolve(flag_key, default_value, expected_type, evaluation_context)
|
|
62
68
|
flag = Subflag::Rails::Flag.find_by(key: flag_key)
|
|
63
69
|
|
|
64
70
|
unless flag&.enabled?
|
|
65
71
|
return resolution(default_value, reason: :default)
|
|
66
72
|
end
|
|
67
73
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
70
101
|
end
|
|
71
102
|
|
|
72
103
|
def resolution(value, reason:, variant: nil)
|
|
@@ -48,6 +48,9 @@ module Subflag
|
|
|
48
48
|
# Set to nil to disable cross-request caching (default).
|
|
49
49
|
attr_accessor :cache_ttl
|
|
50
50
|
|
|
51
|
+
# @return [Proc, nil] Admin authentication callback for the admin UI
|
|
52
|
+
attr_reader :admin_auth_callback
|
|
53
|
+
|
|
51
54
|
def initialize
|
|
52
55
|
@backend = :subflag
|
|
53
56
|
@api_key = nil
|
|
@@ -56,6 +59,7 @@ module Subflag
|
|
|
56
59
|
@logging_enabled = false
|
|
57
60
|
@log_level = :debug
|
|
58
61
|
@cache_ttl = nil
|
|
62
|
+
@admin_auth_callback = nil
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
# Set the backend with validation
|
|
@@ -116,6 +120,25 @@ module Subflag
|
|
|
116
120
|
|
|
117
121
|
@user_context_block.call(user)
|
|
118
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
|
|
119
142
|
end
|
|
120
143
|
end
|
|
121
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
|