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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +68 -38
- 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/subflag/rails/models/flag.rb +15 -2
- data/lib/subflag/rails/targeting_engine.rb +43 -6
- data/lib/subflag/rails/version.rb +1 -1
- metadata +25 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 474015cc849b73a4360fa11418161fbc78c6bbc163596b5b9add7e45929b6db4
|
|
4
|
+
data.tar.gz: 25f595010b6722d107b0604774ac089386e9d3ced39d7fef72411964a310140c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
3
|
+
Feature flags that return strings, numbers, JSON — not just booleans.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
+
Target different values to different users:
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
subflag_value(:max_projects, default:
|
|
25
|
+
# Premium users get 100, free users get 10
|
|
26
|
+
subflag_value(:max_projects, default: 10)
|
|
22
27
|
```
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
Self-hosted or cloud. Same API.
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
## Quick Start
|
|
27
32
|
|
|
28
33
|
```ruby
|
|
29
|
-
gem
|
|
34
|
+
gem "subflag-rails"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
rails generate subflag:install --backend=active_record
|
|
39
|
+
rails db:migrate
|
|
40
|
+
```
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
```ruby
|
|
43
|
+
# config/routes.rb
|
|
44
|
+
mount Subflag::Rails::Engine => "/subflag"
|
|
33
45
|
```
|
|
34
46
|
|
|
35
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
+

|
|
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})">×</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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
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
|
|
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)
|
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.
|
|
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
|
+
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
|
|
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
|