subflag-rails 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd3dfb04635e013ed8a9da2282150acf511c8704aace3c439a06f7e5dd0ac24a
4
- data.tar.gz: adc804044699b6cea75d8b44fc742d08f2a96b24092a8e66d19b419f41c45fa1
3
+ metadata.gz: 474015cc849b73a4360fa11418161fbc78c6bbc163596b5b9add7e45929b6db4
4
+ data.tar.gz: 25f595010b6722d107b0604774ac089386e9d3ced39d7fef72411964a310140c
5
5
  SHA512:
6
- metadata.gz: eb94613ee232272a8799ff0f7623c02d2d6e398ed0b7fbb231ff48bdf1e160ff99cb2110622567374012de4418e9b40129f7b14b0fabe7624904c399696f6dfc
7
- data.tar.gz: 6327d0185659e82b95a922380cfbae030a42f91676a5a00d1a5fb43120c46eedd8d88432c0ec5cc7d20724494abd57be11ac6c47e2da26b536daa159174bf9ac
6
+ metadata.gz: afbe39727f8e70a3273b77e49bf8127debc459487b4d01bc14693c682082592b11fd3506388a8db2bd5021e9bbb914767e19f279a56a56a30c114fe79f10b0f2
7
+ data.tar.gz: f124c9b3103ff13453615be92dfe7639fabfeb042d514bc4232b5a73e8fa1dcbe812e4d0fb418c45a35c33ac0e71753958d37cff01c6742ff991a408e637946f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.6.0] - 2025-12-15
6
+
7
+ ### Added
8
+
9
+ - **Percentage rollouts for ActiveRecord backend**: Gradually roll out features to a percentage of users
10
+ - Deterministic assignment using MurmurHash3 (same user always gets the same result)
11
+ - Combine with segment conditions (e.g., "50% of pro users")
12
+ - Configure via Admin UI or targeting rules JSON
13
+ - New dependency: `murmurhash3` gem for consistent hashing
14
+
15
+ ### Changed
16
+
17
+ - `TargetingEngine.evaluate` now accepts optional `flag_key:` parameter (required for percentage rollouts)
18
+ - Targeting rules validation now accepts `percentage` as alternative to `conditions`
19
+
20
+ ## [0.5.1] - 2025-12-15
21
+
22
+ ### Fixed
23
+
24
+ - **Admin UI not loading**: Include `app/` and `config/` directories in gem package
25
+ - Previously only `lib/` was packaged, causing mounted Engine to fail
26
+
27
+ ## [0.5.0] - 2025-12-11
28
+
29
+ ### Added
30
+
31
+ - **Admin UI**: Mount at `/subflag` to manage flags visually
32
+ - List, create, edit, and delete flags
33
+ - Toggle flags enabled/disabled
34
+ - Visual targeting rule builder (no JSON editing required)
35
+ - Test rules against sample contexts
36
+ - Configurable authentication via `config.admin_auth`
37
+ - **Targeting rules for ActiveRecord backend**: Return different values based on user attributes
38
+ - 12 comparison operators: EQUALS, NOT_EQUALS, IN, NOT_IN, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, MATCHES
39
+ - AND/OR condition groups
40
+ - First-match evaluation order
41
+ - **TargetingEngine**: Evaluates rules against user context
42
+
43
+ ### Changed
44
+
45
+ - `subflag_flags` table now includes `targeting_rules` column (JSON/JSONB)
46
+ - Generator creates migration with JSONB for PostgreSQL, JSON for other databases
47
+
48
+ ## [0.4.0] - 2025-12-09
49
+
50
+ ### Added
51
+
52
+ - **Selectable backends**: Choose where flags are stored
53
+ - `:subflag` - Subflag Cloud (default)
54
+ - `:active_record` - Self-hosted, flags in your database
55
+ - `:memory` - In-memory for testing
56
+ - **ActiveRecord backend**: Store flags in `subflag_flags` table
57
+ - **Memory backend**: Programmatic flag management for tests
58
+ - Generator `--backend` option to configure storage
59
+
5
60
  ## [0.3.0] - 2025-12-07
6
61
 
7
62
  ### Added
data/README.md CHANGED
@@ -1,41 +1,72 @@
1
1
  # Subflag Rails
2
2
 
3
- Typed feature flags for Rails. Booleans, strings, numbers, and JSON — with pluggable backends.
3
+ Feature flags that return strings, numbers, JSON — not just booleans.
4
4
 
5
- [Subflag](https://subflag.com)
6
-
7
- ## Backends
5
+ ```ruby
6
+ limit = subflag_value(:upload_limit_mb, default: 10)
7
+ tier_config = subflag_value(:pricing_tiers, default: { basic: 5, pro: 50 })
8
+ welcome = subflag_value(:welcome_message, default: "Hello")
9
+ ```
8
10
 
9
- Choose where your flags live:
11
+ Target different values to different users:
10
12
 
11
- | Backend | Use Case | Flags Stored In |
12
- |---------|----------|-----------------|
13
- | `:subflag` | Production with dashboard, environments, targeting | Subflag Cloud |
14
- | `:active_record` | Self-hosted, no external dependencies, [built-in admin UI](#admin-ui-activerecord) | Your database |
15
- | `:memory` | Testing and development | In-memory hash |
13
+ ```ruby
14
+ # config/initializers/subflag.rb
15
+ Subflag::Rails.configure do |config|
16
+ config.backend = :active_record
16
17
 
17
- **Same API regardless of backend:**
18
+ config.user_context do |user|
19
+ { targeting_key: user.id.to_s, plan: user.plan, email: user.email }
20
+ end
21
+ end
22
+ ```
18
23
 
19
24
  ```ruby
20
- subflag_enabled?(:new_checkout) # Works with any backend
21
- subflag_value(:max_projects, default: 3) # Works with any backend
25
+ # Premium users get 100, free users get 10
26
+ subflag_value(:max_projects, default: 10)
22
27
  ```
23
28
 
24
- ## Installation
29
+ Self-hosted or cloud. Same API.
25
30
 
26
- Add to your Gemfile:
31
+ ## Quick Start
27
32
 
28
33
  ```ruby
29
- gem 'subflag-rails'
34
+ gem "subflag-rails"
35
+ ```
36
+
37
+ ```bash
38
+ rails generate subflag:install --backend=active_record
39
+ rails db:migrate
40
+ ```
30
41
 
31
- # If using Subflag Cloud (backend: :subflag), also add:
32
- gem 'subflag-openfeature-provider'
42
+ ```ruby
43
+ # config/routes.rb
44
+ mount Subflag::Rails::Engine => "/subflag"
33
45
  ```
34
46
 
35
- ### Option 1: Subflag Cloud (Default)
47
+ No external dependencies. Admin UI included.
48
+
49
+ ---
50
+
51
+ ## Backends
52
+
53
+ Choose where your flags live:
54
+
55
+ | Backend | Use Case | Flags Stored In |
56
+ |---------|----------|-----------------|
57
+ | `:active_record` | Self-hosted, no external dependencies, [built-in admin UI](#admin-ui-activerecord) | Your database |
58
+ | `:subflag` | Dashboard, environments, percentage rollouts | [Subflag Cloud](https://subflag.com) |
59
+ | `:memory` | Testing and development | In-memory hash |
60
+
61
+ ### Subflag Cloud
36
62
 
37
63
  Dashboard, environments, percentage rollouts, and user targeting.
38
64
 
65
+ ```ruby
66
+ gem "subflag-rails"
67
+ gem "subflag-openfeature-provider"
68
+ ```
69
+
39
70
  ```bash
40
71
  rails generate subflag:install
41
72
  ```
@@ -53,24 +84,7 @@ subflag:
53
84
 
54
85
  Or set the `SUBFLAG_API_KEY` environment variable.
55
86
 
56
- ### Option 2: ActiveRecord (Self-Hosted)
57
-
58
- Flags stored in your database. No external dependencies.
59
-
60
- ```bash
61
- rails generate subflag:install --backend=active_record
62
- rails db:migrate
63
- ```
64
-
65
- Create flags directly:
66
-
67
- ```ruby
68
- Subflag::Rails::Flag.create!(key: "new-checkout", value: "true", value_type: "boolean")
69
- Subflag::Rails::Flag.create!(key: "max-projects", value: "100", value_type: "integer")
70
- Subflag::Rails::Flag.create!(key: "welcome-message", value: "Hello!", value_type: "string")
71
- ```
72
-
73
- ### Option 3: Memory (Testing)
87
+ ### Memory (Testing)
74
88
 
75
89
  In-memory flags for tests and local development.
76
90
 
@@ -342,7 +356,9 @@ end
342
356
 
343
357
  ### ActiveRecord Flag Model
344
358
 
345
- When using `backend: :active_record`, flags are stored in the `subflag_flags` table:
359
+ > **Note:** This section only applies when using `backend: :active_record`. For Subflag Cloud, manage flags in the [dashboard](https://app.subflag.com).
360
+
361
+ Flags are stored in the `subflag_flags` table:
346
362
 
347
363
  | Column | Type | Description |
348
364
  |--------|------|-------------|
@@ -466,6 +482,18 @@ Subflag::Rails::Flag.create!(
466
482
  }
467
483
  ```
468
484
 
485
+ **Percentage rollouts:**
486
+
487
+ Gradually roll out features to a percentage of users using the Admin UI:
488
+
489
+ 1. Create or edit a flag at `/subflag`
490
+ 2. Add a targeting rule with a **percentage** (0-100)
491
+ 3. Optionally combine with conditions (e.g., "50% of pro users")
492
+
493
+ Assignment is deterministic — the same user always gets the same result for the same flag.
494
+
495
+ > **Note:** Percentage rollouts require `targeting_key` in your user context (typically the user ID).
496
+
469
497
  **How evaluation works:**
470
498
 
471
499
  1. Flag disabled? → return code default
@@ -486,6 +514,8 @@ Rails.application.routes.draw do
486
514
  end
487
515
  ```
488
516
 
517
+ ![Subflag Admin UI](https://raw.githubusercontent.com/subflag/sdk/main/packages/subflag-rails/docs/admin-ui.png)
518
+
489
519
  The admin UI provides:
490
520
  - List, create, edit, and delete flags
491
521
  - Toggle flags enabled/disabled
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ class ApplicationController < ActionController::Base
6
+ include ActionController::Flash
7
+
8
+ protect_from_forgery with: :exception
9
+
10
+ before_action :authenticate_admin!
11
+
12
+ private
13
+
14
+ def authenticate_admin!
15
+ auth_callback = Subflag::Rails.configuration.admin_auth_callback
16
+ return unless auth_callback
17
+
18
+ instance_exec(&auth_callback)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ class FlagsController < ApplicationController
6
+ before_action :set_flag, only: %i[show edit update destroy toggle test]
7
+
8
+ def index
9
+ @flags = Flag.order(:key)
10
+ end
11
+
12
+ def show; end
13
+
14
+ def new
15
+ @flag = Flag.new(enabled: true, value_type: "boolean", value: "false")
16
+ end
17
+
18
+ def create
19
+ @flag = Flag.new(flag_params)
20
+ if @flag.save
21
+ redirect_to flags_path, notice: "Flag created."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def edit; end
28
+
29
+ def update
30
+ if @flag.update(flag_params)
31
+ redirect_to edit_flag_path(@flag), notice: "Flag updated."
32
+ else
33
+ render :edit, status: :unprocessable_entity
34
+ end
35
+ end
36
+
37
+ def destroy
38
+ @flag.destroy
39
+ redirect_to flags_path, notice: "Flag deleted."
40
+ end
41
+
42
+ def toggle
43
+ @flag.update!(enabled: !@flag.enabled)
44
+ redirect_to flags_path, notice: "Flag #{@flag.enabled? ? 'enabled' : 'disabled'}."
45
+ end
46
+
47
+ def test
48
+ context = parse_test_context(params[:context])
49
+ @test_result = @flag.evaluate(context: context)
50
+ @test_context = context
51
+
52
+ respond_to do |format|
53
+ format.html { render :edit }
54
+ format.json { render json: { result: @test_result, context: @test_context } }
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def set_flag
61
+ @flag = Flag.find(params[:id])
62
+ end
63
+
64
+ def flag_params
65
+ params.require(:flag).permit(:key, :value, :value_type, :enabled, :description).tap do |p|
66
+ if params[:flag][:targeting_rules].present?
67
+ p[:targeting_rules] = JSON.parse(params[:flag][:targeting_rules])
68
+ else
69
+ p[:targeting_rules] = []
70
+ end
71
+ end
72
+ rescue JSON::ParserError
73
+ params.require(:flag).permit(:key, :value, :value_type, :enabled, :description, :targeting_rules)
74
+ end
75
+
76
+ def parse_test_context(context_json)
77
+ return {} if context_json.blank?
78
+
79
+ JSON.parse(context_json).transform_keys(&:to_sym)
80
+ rescue JSON::ParserError
81
+ {}
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,72 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Subflag Admin</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; line-height: 1.5; }
10
+ a { color: #0066cc; text-decoration: none; }
11
+ a:hover { text-decoration: underline; }
12
+
13
+ .container { max-width: 900px; margin: 0 auto; padding: 20px; }
14
+ .header { background: #fff; border-bottom: 1px solid #ddd; padding: 15px 20px; margin-bottom: 20px; }
15
+ .header h1 { font-size: 18px; font-weight: 600; }
16
+ .header a { color: #333; }
17
+
18
+ .card { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 20px; margin-bottom: 20px; }
19
+ .card h2 { font-size: 16px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
20
+
21
+ .notice { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 10px 15px; border-radius: 4px; margin-bottom: 20px; }
22
+ .alert { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 10px 15px; border-radius: 4px; margin-bottom: 20px; }
23
+ .errors { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 10px 15px; border-radius: 4px; margin-bottom: 20px; }
24
+ .errors ul { margin-left: 20px; }
25
+
26
+ table { width: 100%; border-collapse: collapse; }
27
+ th, td { text-align: left; padding: 10px; border-bottom: 1px solid #eee; }
28
+ th { font-weight: 600; color: #666; font-size: 12px; text-transform: uppercase; }
29
+
30
+ .btn { display: inline-block; padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; background: #fff; color: #333; cursor: pointer; font-size: 14px; }
31
+ .btn:hover { background: #f5f5f5; text-decoration: none; }
32
+ .btn-primary { background: #0066cc; border-color: #0066cc; color: #fff; }
33
+ .btn-primary:hover { background: #0055aa; }
34
+ .btn-danger { background: #dc3545; border-color: #dc3545; color: #fff; }
35
+ .btn-danger:hover { background: #c82333; }
36
+ .btn-sm { padding: 4px 10px; font-size: 12px; }
37
+
38
+ .form-group { margin-bottom: 15px; }
39
+ .form-group label { display: block; margin-bottom: 5px; font-weight: 500; font-size: 14px; }
40
+ .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
41
+ .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #0066cc; }
42
+ .form-group small { color: #666; font-size: 12px; display: block; margin-top: 4px; }
43
+
44
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
45
+ .badge-success { background: #d4edda; color: #155724; }
46
+ .badge-secondary { background: #e9ecef; color: #6c757d; }
47
+
48
+ .actions { display: flex; gap: 8px; }
49
+ .mono { font-family: monospace; font-size: 13px; }
50
+ .text-muted { color: #666; }
51
+ .mt-10 { margin-top: 10px; }
52
+ .mb-10 { margin-bottom: 10px; }
53
+ .flex { display: flex; }
54
+ .justify-between { justify-content: space-between; }
55
+ .items-center { align-items: center; }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div class="header">
60
+ <div class="container flex justify-between items-center" style="padding: 0;">
61
+ <h1><a href="<%= main_app.root_path rescue flags_path %>">Subflag</a></h1>
62
+ <a href="<%= flags_path %>">Flags</a>
63
+ </div>
64
+ </div>
65
+
66
+ <div class="container">
67
+ <% if flash[:notice] %><div class="notice"><%= flash[:notice] %></div><% end %>
68
+ <% if flash[:alert] %><div class="alert"><%= flash[:alert] %></div><% end %>
69
+ <%= yield %>
70
+ </div>
71
+ </body>
72
+ </html>
@@ -0,0 +1,45 @@
1
+ <% if flag.errors.any? %>
2
+ <div class="errors">
3
+ <strong><%= pluralize(flag.errors.count, "error") %>:</strong>
4
+ <ul>
5
+ <% flag.errors.full_messages.each do |msg| %>
6
+ <li><%= msg %></li>
7
+ <% end %>
8
+ </ul>
9
+ </div>
10
+ <% end %>
11
+
12
+ <%= form_with model: flag, url: flag.persisted? ? flag_path(flag) : flags_path, local: true do |f| %>
13
+ <div class="form-group">
14
+ <%= f.label :key %>
15
+ <%= f.text_field :key, placeholder: "my-feature-flag", disabled: flag.persisted? %>
16
+ <small>Lowercase letters, numbers, and dashes only. Cannot be changed after creation.</small>
17
+ </div>
18
+
19
+ <div class="form-group">
20
+ <%= f.label :value_type, "Type" %>
21
+ <%= f.select :value_type, [["Boolean", "boolean"], ["String", "string"], ["Integer", "integer"], ["Float", "float"], ["Object (JSON)", "object"]], {}, disabled: flag.persisted? %>
22
+ <small>Cannot be changed after creation.</small>
23
+ </div>
24
+
25
+ <div class="form-group">
26
+ <%= f.label :value, "Default Value" %>
27
+ <%= f.text_field :value, placeholder: flag.value_type == "boolean" ? "true or false" : "default value" %>
28
+ <small>The value returned when no targeting rules match.</small>
29
+ </div>
30
+
31
+ <div class="form-group">
32
+ <%= f.label :description %>
33
+ <%= f.text_area :description, rows: 2, placeholder: "Optional description..." %>
34
+ </div>
35
+
36
+ <div class="form-group">
37
+ <%= f.label :enabled %>
38
+ <%= f.check_box :enabled %> Flag is enabled
39
+ </div>
40
+
41
+ <div class="mt-10">
42
+ <%= f.submit flag.persisted? ? "Update Flag" : "Create Flag", class: "btn btn-primary" %>
43
+ <a href="<%= flags_path %>" class="btn">Cancel</a>
44
+ </div>
45
+ <% end %>
@@ -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})">&times;</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})">&times;</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>
@@ -0,0 +1,5 @@
1
+ <h2 style="font-size: 20px; margin-bottom: 20px;">New Flag</h2>
2
+
3
+ <div class="card">
4
+ <%= render "form", flag: @flag %>
5
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Subflag::Rails::Engine.routes.draw do
4
+ resources :flags do
5
+ member do
6
+ post :toggle
7
+ post :test
8
+ end
9
+ end
10
+
11
+ root to: "flags#index"
12
+ end
@@ -70,7 +70,7 @@ module Subflag
70
70
  def evaluate(context: nil, expected_type: nil)
71
71
  rules = parsed_targeting_rules
72
72
  raw_value = if rules.present? && context.present?
73
- matched = TargetingEngine.evaluate(rules, context)
73
+ matched = TargetingEngine.evaluate(rules, context, flag_key: key)
74
74
  matched || value
75
75
  else
76
76
  value
@@ -146,7 +146,20 @@ module Subflag
146
146
 
147
147
  rule = rule.transform_keys(&:to_s)
148
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")
149
+
150
+ has_conditions = rule.key?("conditions") && rule["conditions"].present?
151
+ has_percentage = rule.key?("percentage")
152
+
153
+ unless has_conditions || has_percentage
154
+ errors.add(:targeting_rules, "rule #{index} must have 'conditions' and/or 'percentage'")
155
+ end
156
+
157
+ if has_percentage
158
+ pct = rule["percentage"]
159
+ unless pct.is_a?(Numeric) && pct >= 0 && pct <= 100
160
+ errors.add(:targeting_rules, "rule #{index} percentage must be a number between 0 and 100")
161
+ end
162
+ end
150
163
  end
151
164
  end
152
165
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "murmurhash3"
4
+
3
5
  module Subflag
4
6
  module Rails
5
7
  # Evaluates targeting rules against evaluation contexts.
@@ -34,25 +36,56 @@ module Subflag
34
36
  #
35
37
  # @param rules [Array<Hash>, nil] Array of targeting rules with values
36
38
  # @param context [Hash, nil] Evaluation context with attributes
39
+ # @param flag_key [String, nil] The flag key (required for percentage rollouts)
37
40
  # @return [String, nil] The matched rule's value, or nil if no match
38
- def evaluate(rules, context)
41
+ def evaluate(rules, context, flag_key: nil)
39
42
  return nil if rules.nil? || rules.empty?
40
43
  return nil if context.nil? || context.empty?
41
44
 
42
- # Normalize context keys to strings for comparison
43
45
  normalized_context = normalize_context(context)
46
+ targeting_key = extract_targeting_key(normalized_context)
44
47
 
45
48
  rules.each do |rule|
46
49
  rule = rule.transform_keys(&:to_s)
47
50
  conditions = rule["conditions"]
48
- next unless conditions
51
+ percentage = rule["percentage"]
52
+
53
+ segment_matches = if conditions.nil? || conditions.empty?
54
+ true
55
+ else
56
+ evaluate_rule(conditions, normalized_context)
57
+ end
58
+
59
+ next unless segment_matches
49
60
 
50
- if evaluate_rule(conditions, normalized_context)
51
- return rule["value"]
61
+ if percentage
62
+ next unless targeting_key && flag_key
63
+ next unless evaluate_percentage(targeting_key, flag_key, percentage)
52
64
  end
65
+
66
+ return rule["value"]
53
67
  end
54
68
 
55
- nil # No rules matched
69
+ nil
70
+ end
71
+
72
+ # Evaluate percentage rollout using MurmurHash3
73
+ #
74
+ # @param targeting_key [String] Unique identifier for the context
75
+ # @param flag_key [String] The flag being evaluated
76
+ # @param percentage [Integer] Target percentage (0-100)
77
+ # @return [Boolean] true if context falls within percentage
78
+ def evaluate_percentage(targeting_key, flag_key, percentage)
79
+ percentage = percentage.to_i
80
+ return false if percentage <= 0
81
+ return true if percentage >= 100
82
+
83
+ hash_input = "#{targeting_key}:#{flag_key}"
84
+ hash_bytes = MurmurHash3::V128.str_hash(hash_input)
85
+ hash_code = [hash_bytes[0]].pack("L").unpack1("l")
86
+ bucket = hash_code.abs % 100
87
+
88
+ bucket < percentage
56
89
  end
57
90
 
58
91
  private
@@ -63,6 +96,10 @@ module Subflag
63
96
  end
64
97
  end
65
98
 
99
+ def extract_targeting_key(context)
100
+ context["targeting_key"] || context["targetingKey"]
101
+ end
102
+
66
103
  # Evaluate an AND/OR rule block
67
104
  def evaluate_rule(rule, context)
68
105
  rule = rule.transform_keys(&:to_s)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subflag-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.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 00:00:00.000000000 Z
11
+ date: 2025-12-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: openfeature-sdk
@@ -58,6 +58,20 @@ dependencies:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
60
  version: '6.1'
61
+ - !ruby/object:Gem::Dependency
62
+ name: murmurhash3
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.1.6
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.1.6
61
75
  - !ruby/object:Gem::Dependency
62
76
  name: bundler
63
77
  requirement: !ruby/object:Gem::Requirement
@@ -170,7 +184,7 @@ dependencies:
170
184
  - - ">="
171
185
  - !ruby/object:Gem::Version
172
186
  version: '6.1'
173
- description: Feature flags for Rails with pluggable backends. Use Subflag Cloud (SaaS),
187
+ description: Feature flags for Rails with selectable backends. Use Subflag Cloud (SaaS),
174
188
  ActiveRecord (self-hosted), or Memory (testing). Get typed values (boolean, string,
175
189
  integer, double, object) with the same API regardless of backend.
176
190
  email:
@@ -182,6 +196,14 @@ files:
182
196
  - CHANGELOG.md
183
197
  - LICENSE.txt
184
198
  - README.md
199
+ - app/controllers/subflag/rails/application_controller.rb
200
+ - app/controllers/subflag/rails/flags_controller.rb
201
+ - app/views/layouts/subflag/rails/application.html.erb
202
+ - app/views/subflag/rails/flags/_form.html.erb
203
+ - app/views/subflag/rails/flags/edit.html.erb
204
+ - app/views/subflag/rails/flags/index.html.erb
205
+ - app/views/subflag/rails/flags/new.html.erb
206
+ - config/routes.rb
185
207
  - lib/generators/subflag/install_generator.rb
186
208
  - lib/generators/subflag/templates/create_subflag_flags.rb.tt
187
209
  - lib/generators/subflag/templates/initializer.rb.tt