subflag-rails 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +147 -2
- 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 +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd3dfb04635e013ed8a9da2282150acf511c8704aace3c439a06f7e5dd0ac24a
|
|
4
|
+
data.tar.gz: adc804044699b6cea75d8b44fc742d08f2a96b24092a8e66d19b419f41c45fa1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb94613ee232272a8799ff0f7623c02d2d6e398ed0b7fbb231ff48bdf1e160ff99cb2110622567374012de4418e9b40129f7b14b0fabe7624904c399696f6dfc
|
|
7
|
+
data.tar.gz: 6327d0185659e82b95a922380cfbae030a42f91676a5a00d1a5fb43120c46eedd8d88432c0ec5cc7d20724494abd57be11ac6c47e2da26b536daa159174bf9ac
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Choose where your flags live:
|
|
|
11
11
|
| Backend | Use Case | Flags Stored In |
|
|
12
12
|
|---------|----------|-----------------|
|
|
13
13
|
| `:subflag` | Production with dashboard, environments, targeting | Subflag Cloud |
|
|
14
|
-
| `:active_record` | Self-hosted, no external dependencies | Your database |
|
|
14
|
+
| `:active_record` | Self-hosted, no external dependencies, [built-in admin UI](#admin-ui-activerecord) | Your database |
|
|
15
15
|
| `:memory` | Testing and development | In-memory hash |
|
|
16
16
|
|
|
17
17
|
**Same API regardless of backend:**
|
|
@@ -347,10 +347,11 @@ When using `backend: :active_record`, flags are stored in the `subflag_flags` ta
|
|
|
347
347
|
| Column | Type | Description |
|
|
348
348
|
|--------|------|-------------|
|
|
349
349
|
| `key` | string | Flag name (lowercase, dashes, e.g., `new-checkout`) |
|
|
350
|
-
| `value` | text |
|
|
350
|
+
| `value` | text | Default value (what everyone gets) |
|
|
351
351
|
| `value_type` | string | Type: `boolean`, `string`, `integer`, `float`, `object` |
|
|
352
352
|
| `enabled` | boolean | Whether the flag is active (default: true) |
|
|
353
353
|
| `description` | text | Optional description |
|
|
354
|
+
| `targeting_rules` | json | Optional rules for showing different values to different users |
|
|
354
355
|
|
|
355
356
|
```ruby
|
|
356
357
|
# Create flags
|
|
@@ -363,6 +364,150 @@ Subflag::Rails::Flag.enabled.find_each { |f| puts "#{f.key}: #{f.typed_value}" }
|
|
|
363
364
|
Subflag::Rails::Flag.find_by(key: "new-checkout")&.update!(enabled: false)
|
|
364
365
|
```
|
|
365
366
|
|
|
367
|
+
### Targeting Rules (ActiveRecord)
|
|
368
|
+
|
|
369
|
+
Show different flag values to different users based on their attributes. Perfect for internal testing before wider rollout.
|
|
370
|
+
|
|
371
|
+
> **Tip:** Use the [Admin UI](#admin-ui-activerecord) to manage targeting rules visually instead of editing JSON.
|
|
372
|
+
|
|
373
|
+
**First, configure user context:**
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
# config/initializers/subflag.rb
|
|
377
|
+
Subflag::Rails.configure do |config|
|
|
378
|
+
config.backend = :active_record
|
|
379
|
+
|
|
380
|
+
config.user_context do |user|
|
|
381
|
+
{
|
|
382
|
+
targeting_key: user.id.to_s,
|
|
383
|
+
email: user.email,
|
|
384
|
+
role: user.role, # e.g., "admin", "developer", "qa"
|
|
385
|
+
plan: user.plan # e.g., "free", "pro", "enterprise"
|
|
386
|
+
}
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Create flags with targeting rules:**
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
# Internal team sees new feature, everyone else sees old
|
|
395
|
+
Subflag::Rails::Flag.create!(
|
|
396
|
+
key: "new-dashboard",
|
|
397
|
+
value: "false", # Default: everyone gets false
|
|
398
|
+
value_type: "boolean",
|
|
399
|
+
targeting_rules: [
|
|
400
|
+
{
|
|
401
|
+
"value" => "true", # Internal team gets true
|
|
402
|
+
"conditions" => {
|
|
403
|
+
"type" => "OR",
|
|
404
|
+
"conditions" => [
|
|
405
|
+
{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@yourcompany.com" },
|
|
406
|
+
{ "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer", "qa"] }
|
|
407
|
+
]
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
]
|
|
411
|
+
)
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**Progressive rollout with multiple rules (first match wins):**
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
Subflag::Rails::Flag.create!(
|
|
418
|
+
key: "max-projects",
|
|
419
|
+
value: "5", # Default: everyone gets 5
|
|
420
|
+
value_type: "integer",
|
|
421
|
+
targeting_rules: [
|
|
422
|
+
{ "value" => "1000", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }] } },
|
|
423
|
+
{ "value" => "100", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@yourcompany.com" }] } },
|
|
424
|
+
{ "value" => "25", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "plan", "operator" => "EQUALS", "value" => "pro" }] } }
|
|
425
|
+
]
|
|
426
|
+
)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**Supported operators:**
|
|
430
|
+
|
|
431
|
+
| Operator | Example | Description |
|
|
432
|
+
|----------|---------|-------------|
|
|
433
|
+
| `EQUALS` | `{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }` | Exact match |
|
|
434
|
+
| `NOT_EQUALS` | `{ "attribute" => "env", "operator" => "NOT_EQUALS", "value" => "prod" }` | Not equal |
|
|
435
|
+
| `IN` | `{ "attribute" => "role", "operator" => "IN", "value" => ["admin", "qa"] }` | Value in list |
|
|
436
|
+
| `NOT_IN` | `{ "attribute" => "country", "operator" => "NOT_IN", "value" => ["RU", "CN"] }` | Value not in list |
|
|
437
|
+
| `CONTAINS` | `{ "attribute" => "email", "operator" => "CONTAINS", "value" => "test" }` | String contains |
|
|
438
|
+
| `NOT_CONTAINS` | `{ "attribute" => "email", "operator" => "NOT_CONTAINS", "value" => "spam" }` | String doesn't contain |
|
|
439
|
+
| `STARTS_WITH` | `{ "attribute" => "user_id", "operator" => "STARTS_WITH", "value" => "test-" }` | String prefix |
|
|
440
|
+
| `ENDS_WITH` | `{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" }` | String suffix |
|
|
441
|
+
| `GREATER_THAN` | `{ "attribute" => "age", "operator" => "GREATER_THAN", "value" => 18 }` | Numeric greater than |
|
|
442
|
+
| `LESS_THAN` | `{ "attribute" => "items", "operator" => "LESS_THAN", "value" => 100 }` | Numeric less than |
|
|
443
|
+
| `GREATER_THAN_OR_EQUAL` | `{ "attribute" => "score", "operator" => "GREATER_THAN_OR_EQUAL", "value" => 80 }` | Numeric greater or equal |
|
|
444
|
+
| `LESS_THAN_OR_EQUAL` | `{ "attribute" => "score", "operator" => "LESS_THAN_OR_EQUAL", "value" => 50 }` | Numeric less or equal |
|
|
445
|
+
| `MATCHES` | `{ "attribute" => "email", "operator" => "MATCHES", "value" => ".*@company\\.com$" }` | Regex match |
|
|
446
|
+
|
|
447
|
+
**Combining conditions:**
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
# OR: any condition matches
|
|
451
|
+
{
|
|
452
|
+
"type" => "OR",
|
|
453
|
+
"conditions" => [
|
|
454
|
+
{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
|
|
455
|
+
{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }
|
|
456
|
+
]
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
# AND: all conditions must match
|
|
460
|
+
{
|
|
461
|
+
"type" => "AND",
|
|
462
|
+
"conditions" => [
|
|
463
|
+
{ "attribute" => "plan", "operator" => "EQUALS", "value" => "enterprise" },
|
|
464
|
+
{ "attribute" => "country", "operator" => "IN", "value" => ["US", "CA"] }
|
|
465
|
+
]
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**How evaluation works:**
|
|
470
|
+
|
|
471
|
+
1. Flag disabled? → return code default
|
|
472
|
+
2. For each rule (in order): if context matches → return rule's value
|
|
473
|
+
3. No rules matched? → return flag's default `value`
|
|
474
|
+
|
|
475
|
+
This lets you progressively widen access by adding rules, without changing existing ones.
|
|
476
|
+
|
|
477
|
+
### Admin UI (ActiveRecord)
|
|
478
|
+
|
|
479
|
+
Mount the admin UI to manage flags and targeting rules visually:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
# config/routes.rb
|
|
483
|
+
Rails.application.routes.draw do
|
|
484
|
+
mount Subflag::Rails::Engine => "/subflag"
|
|
485
|
+
# ... your other routes
|
|
486
|
+
end
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
The admin UI provides:
|
|
490
|
+
- List, create, edit, and delete flags
|
|
491
|
+
- Toggle flags enabled/disabled
|
|
492
|
+
- Visual targeting rule builder (no JSON editing)
|
|
493
|
+
- Test rules against sample contexts
|
|
494
|
+
|
|
495
|
+
**Secure the admin UI:**
|
|
496
|
+
|
|
497
|
+
```ruby
|
|
498
|
+
# config/initializers/subflag.rb
|
|
499
|
+
Subflag::Rails.configure do |config|
|
|
500
|
+
config.backend = :active_record
|
|
501
|
+
|
|
502
|
+
# Require authentication for admin UI
|
|
503
|
+
config.admin_auth do
|
|
504
|
+
redirect_to main_app.root_path unless current_user&.admin?
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Visit `/subflag` in your browser to access the admin UI.
|
|
510
|
+
|
|
366
511
|
## Testing
|
|
367
512
|
|
|
368
513
|
Stub flags in your tests:
|
|
@@ -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
|
|
@@ -2,27 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
module Subflag
|
|
4
4
|
module Rails
|
|
5
|
-
# ActiveRecord model for storing feature flags in your database
|
|
5
|
+
# ActiveRecord model for storing feature flags in your database.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
7
|
+
# Supports targeting rules to show different values to different users.
|
|
8
|
+
# Perfect for internal testing before wider rollout.
|
|
9
|
+
#
|
|
10
|
+
# @example Simple flag (everyone gets the same value)
|
|
8
11
|
# Subflag::Rails::Flag.create!(
|
|
9
12
|
# key: "new-checkout",
|
|
10
13
|
# value: "true",
|
|
11
14
|
# value_type: "boolean"
|
|
12
15
|
# )
|
|
13
16
|
#
|
|
14
|
-
# @example
|
|
17
|
+
# @example Flag with targeting rules (internal team gets different value)
|
|
15
18
|
# Subflag::Rails::Flag.create!(
|
|
16
|
-
# key: "
|
|
17
|
-
# value: "
|
|
18
|
-
# value_type: "
|
|
19
|
+
# key: "new-dashboard",
|
|
20
|
+
# value: "false",
|
|
21
|
+
# value_type: "boolean",
|
|
22
|
+
# targeting_rules: [
|
|
23
|
+
# {
|
|
24
|
+
# "value" => "true",
|
|
25
|
+
# "conditions" => {
|
|
26
|
+
# "type" => "OR",
|
|
27
|
+
# "conditions" => [
|
|
28
|
+
# { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
|
|
29
|
+
# { "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer", "qa"] }
|
|
30
|
+
# ]
|
|
31
|
+
# }
|
|
32
|
+
# }
|
|
33
|
+
# ]
|
|
19
34
|
# )
|
|
20
35
|
#
|
|
21
|
-
# @example
|
|
36
|
+
# @example Progressive rollout (first match wins)
|
|
22
37
|
# Subflag::Rails::Flag.create!(
|
|
23
|
-
# key: "
|
|
24
|
-
# value:
|
|
25
|
-
# value_type: "
|
|
38
|
+
# key: "max-projects",
|
|
39
|
+
# value: "5",
|
|
40
|
+
# value_type: "integer",
|
|
41
|
+
# targeting_rules: [
|
|
42
|
+
# { "value" => "1000", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }] } },
|
|
43
|
+
# { "value" => "100", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" }] } },
|
|
44
|
+
# { "value" => "25", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "plan", "operator" => "EQUALS", "value" => "pro" }] } }
|
|
45
|
+
# ]
|
|
26
46
|
# )
|
|
27
47
|
#
|
|
28
48
|
class Flag < ::ActiveRecord::Base
|
|
@@ -35,29 +55,98 @@ module Subflag
|
|
|
35
55
|
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and dashes" }
|
|
36
56
|
validates :value_type, inclusion: { in: VALUE_TYPES }
|
|
37
57
|
validates :value, presence: true
|
|
58
|
+
validate :validate_targeting_rules
|
|
38
59
|
|
|
39
60
|
scope :enabled, -> { where(enabled: true) }
|
|
40
61
|
|
|
41
|
-
#
|
|
62
|
+
# Evaluate the flag for a given context
|
|
63
|
+
#
|
|
64
|
+
# Returns the matched rule's value if context matches targeting rules,
|
|
65
|
+
# otherwise returns the default value.
|
|
66
|
+
#
|
|
67
|
+
# @param context [Hash, nil] Evaluation context with user attributes
|
|
68
|
+
# @param expected_type [Symbol, String, nil] Override the value_type for casting
|
|
69
|
+
# @return [Object] The evaluated value, cast to the appropriate type
|
|
70
|
+
def evaluate(context: nil, expected_type: nil)
|
|
71
|
+
rules = parsed_targeting_rules
|
|
72
|
+
raw_value = if rules.present? && context.present?
|
|
73
|
+
matched = TargetingEngine.evaluate(rules, context)
|
|
74
|
+
matched || value
|
|
75
|
+
else
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
cast_value(raw_value, expected_type)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get the flag's default value cast to its declared type (ignores targeting)
|
|
42
83
|
#
|
|
43
84
|
# @param expected_type [Symbol, String, nil] Override the value_type for casting
|
|
44
85
|
# @return [Object] The typed value
|
|
45
86
|
def typed_value(expected_type = nil)
|
|
87
|
+
cast_value(value, expected_type)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def cast_value(raw_value, expected_type = nil)
|
|
46
93
|
type = expected_type&.to_s || value_type
|
|
47
94
|
|
|
48
95
|
case type.to_s
|
|
49
96
|
when "boolean"
|
|
50
|
-
ActiveModel::Type::Boolean.new.cast(
|
|
97
|
+
ActiveModel::Type::Boolean.new.cast(raw_value)
|
|
51
98
|
when "string"
|
|
52
|
-
|
|
99
|
+
raw_value.to_s
|
|
53
100
|
when "integer"
|
|
54
|
-
|
|
101
|
+
raw_value.to_i
|
|
55
102
|
when "float", "number"
|
|
56
|
-
|
|
103
|
+
raw_value.to_f
|
|
57
104
|
when "object"
|
|
58
|
-
|
|
105
|
+
raw_value.is_a?(Hash) ? raw_value : JSON.parse(raw_value)
|
|
59
106
|
else
|
|
60
|
-
|
|
107
|
+
raw_value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parsed_targeting_rules
|
|
112
|
+
return nil if targeting_rules.blank?
|
|
113
|
+
|
|
114
|
+
case targeting_rules
|
|
115
|
+
when String
|
|
116
|
+
JSON.parse(targeting_rules)
|
|
117
|
+
when Array
|
|
118
|
+
targeting_rules
|
|
119
|
+
else
|
|
120
|
+
targeting_rules
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_targeting_rules
|
|
125
|
+
return if targeting_rules.blank?
|
|
126
|
+
|
|
127
|
+
rules = case targeting_rules
|
|
128
|
+
when Array then targeting_rules
|
|
129
|
+
when String
|
|
130
|
+
begin
|
|
131
|
+
JSON.parse(targeting_rules)
|
|
132
|
+
rescue JSON::ParserError
|
|
133
|
+
errors.add(:targeting_rules, "must be valid JSON")
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
errors.add(:targeting_rules, "must be an array of rules")
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
rules.each_with_index do |rule, index|
|
|
142
|
+
unless rule.is_a?(Hash)
|
|
143
|
+
errors.add(:targeting_rules, "rule #{index} must be a hash")
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
rule = rule.transform_keys(&:to_s)
|
|
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")
|
|
61
150
|
end
|
|
62
151
|
end
|
|
63
152
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "targeting_engine"
|
|
4
|
+
|
|
5
|
+
module Subflag
|
|
6
|
+
module Rails
|
|
7
|
+
# Targeting module for evaluating rules that control who sees what flag values.
|
|
8
|
+
#
|
|
9
|
+
# Rules are stored as JSON in the database and evaluated at runtime.
|
|
10
|
+
# Use targeting to roll out features to internal teams before wider release.
|
|
11
|
+
#
|
|
12
|
+
# ## Rule Format
|
|
13
|
+
#
|
|
14
|
+
# Rules are an array of objects, each with a `value` and `conditions`:
|
|
15
|
+
#
|
|
16
|
+
# [
|
|
17
|
+
# {
|
|
18
|
+
# "value" => "true",
|
|
19
|
+
# "conditions" => {
|
|
20
|
+
# "type" => "OR",
|
|
21
|
+
# "conditions" => [
|
|
22
|
+
# { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
|
|
23
|
+
# { "attribute" => "role", "operator" => "IN", "value" => ["admin", "qa"] }
|
|
24
|
+
# ]
|
|
25
|
+
# }
|
|
26
|
+
# }
|
|
27
|
+
# ]
|
|
28
|
+
#
|
|
29
|
+
# ## Supported Operators
|
|
30
|
+
#
|
|
31
|
+
# - EQUALS, NOT_EQUALS - exact match
|
|
32
|
+
# - IN, NOT_IN - list membership
|
|
33
|
+
# - CONTAINS, NOT_CONTAINS - substring match
|
|
34
|
+
# - STARTS_WITH, ENDS_WITH - prefix/suffix match
|
|
35
|
+
# - GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL - numeric
|
|
36
|
+
# - MATCHES - regex match
|
|
37
|
+
#
|
|
38
|
+
# ## Evaluation
|
|
39
|
+
#
|
|
40
|
+
# Rules are evaluated in order. First match wins.
|
|
41
|
+
# If no rules match, the flag's default value is returned.
|
|
42
|
+
#
|
|
43
|
+
# TODO: Add admin UI for managing targeting rules
|
|
44
|
+
#
|
|
45
|
+
module Targeting
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Evaluates targeting rules against evaluation contexts.
|
|
6
|
+
#
|
|
7
|
+
# Rules are arrays of { "value" => X, "conditions" => {...} } hashes.
|
|
8
|
+
# First matching rule wins. Falls back to flag's default value if nothing matches.
|
|
9
|
+
#
|
|
10
|
+
# @example Rule structure
|
|
11
|
+
# [
|
|
12
|
+
# {
|
|
13
|
+
# "value" => "100",
|
|
14
|
+
# "conditions" => {
|
|
15
|
+
# "type" => "OR",
|
|
16
|
+
# "conditions" => [
|
|
17
|
+
# { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
|
|
18
|
+
# { "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer"] }
|
|
19
|
+
# ]
|
|
20
|
+
# }
|
|
21
|
+
# }
|
|
22
|
+
# ]
|
|
23
|
+
#
|
|
24
|
+
module TargetingEngine
|
|
25
|
+
OPERATORS = %w[
|
|
26
|
+
EQUALS NOT_EQUALS IN NOT_IN
|
|
27
|
+
CONTAINS NOT_CONTAINS STARTS_WITH ENDS_WITH
|
|
28
|
+
GREATER_THAN LESS_THAN GREATER_THAN_OR_EQUAL LESS_THAN_OR_EQUAL
|
|
29
|
+
MATCHES
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Evaluate targeting rules against a context
|
|
34
|
+
#
|
|
35
|
+
# @param rules [Array<Hash>, nil] Array of targeting rules with values
|
|
36
|
+
# @param context [Hash, nil] Evaluation context with attributes
|
|
37
|
+
# @return [String, nil] The matched rule's value, or nil if no match
|
|
38
|
+
def evaluate(rules, context)
|
|
39
|
+
return nil if rules.nil? || rules.empty?
|
|
40
|
+
return nil if context.nil? || context.empty?
|
|
41
|
+
|
|
42
|
+
# Normalize context keys to strings for comparison
|
|
43
|
+
normalized_context = normalize_context(context)
|
|
44
|
+
|
|
45
|
+
rules.each do |rule|
|
|
46
|
+
rule = rule.transform_keys(&:to_s)
|
|
47
|
+
conditions = rule["conditions"]
|
|
48
|
+
next unless conditions
|
|
49
|
+
|
|
50
|
+
if evaluate_rule(conditions, normalized_context)
|
|
51
|
+
return rule["value"]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
nil # No rules matched
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def normalize_context(context)
|
|
61
|
+
context.transform_keys(&:to_s).transform_values do |v|
|
|
62
|
+
v.is_a?(Symbol) ? v.to_s : v
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Evaluate an AND/OR rule block
|
|
67
|
+
def evaluate_rule(rule, context)
|
|
68
|
+
rule = rule.transform_keys(&:to_s)
|
|
69
|
+
type = rule["type"]&.upcase || "AND"
|
|
70
|
+
conditions = rule["conditions"] || []
|
|
71
|
+
|
|
72
|
+
case type
|
|
73
|
+
when "AND"
|
|
74
|
+
conditions.all? { |c| evaluate_condition(c, context) }
|
|
75
|
+
when "OR"
|
|
76
|
+
conditions.any? { |c| evaluate_condition(c, context) }
|
|
77
|
+
else
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Evaluate a single condition
|
|
83
|
+
def evaluate_condition(condition, context)
|
|
84
|
+
condition = condition.transform_keys(&:to_s)
|
|
85
|
+
attribute = condition["attribute"]
|
|
86
|
+
operator = condition["operator"]&.upcase
|
|
87
|
+
expected = condition["value"]
|
|
88
|
+
|
|
89
|
+
return false unless attribute && operator
|
|
90
|
+
|
|
91
|
+
actual = context[attribute]
|
|
92
|
+
return false if actual.nil?
|
|
93
|
+
|
|
94
|
+
case operator
|
|
95
|
+
when "EQUALS"
|
|
96
|
+
compare_equals(actual, expected)
|
|
97
|
+
when "NOT_EQUALS"
|
|
98
|
+
!compare_equals(actual, expected)
|
|
99
|
+
when "IN"
|
|
100
|
+
compare_in(actual, expected)
|
|
101
|
+
when "NOT_IN"
|
|
102
|
+
!compare_in(actual, expected)
|
|
103
|
+
when "CONTAINS"
|
|
104
|
+
compare_contains(actual, expected)
|
|
105
|
+
when "NOT_CONTAINS"
|
|
106
|
+
!compare_contains(actual, expected)
|
|
107
|
+
when "STARTS_WITH"
|
|
108
|
+
compare_starts_with(actual, expected)
|
|
109
|
+
when "ENDS_WITH"
|
|
110
|
+
compare_ends_with(actual, expected)
|
|
111
|
+
when "GREATER_THAN"
|
|
112
|
+
compare_numeric(actual, expected) { |a, e| a > e }
|
|
113
|
+
when "LESS_THAN"
|
|
114
|
+
compare_numeric(actual, expected) { |a, e| a < e }
|
|
115
|
+
when "GREATER_THAN_OR_EQUAL"
|
|
116
|
+
compare_numeric(actual, expected) { |a, e| a >= e }
|
|
117
|
+
when "LESS_THAN_OR_EQUAL"
|
|
118
|
+
compare_numeric(actual, expected) { |a, e| a <= e }
|
|
119
|
+
when "MATCHES"
|
|
120
|
+
compare_matches(actual, expected)
|
|
121
|
+
else
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def compare_equals(actual, expected)
|
|
127
|
+
normalize_value(actual) == normalize_value(expected)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def compare_in(actual, expected)
|
|
131
|
+
return false unless expected.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
normalized_actual = normalize_value(actual)
|
|
134
|
+
expected.any? { |e| normalize_value(e) == normalized_actual }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def compare_contains(actual, expected)
|
|
138
|
+
actual.to_s.include?(expected.to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def compare_starts_with(actual, expected)
|
|
142
|
+
actual.to_s.start_with?(expected.to_s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def compare_ends_with(actual, expected)
|
|
146
|
+
actual.to_s.end_with?(expected.to_s)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def compare_numeric(actual, expected)
|
|
150
|
+
a = to_number(actual)
|
|
151
|
+
e = to_number(expected)
|
|
152
|
+
return false if a.nil? || e.nil?
|
|
153
|
+
|
|
154
|
+
yield(a, e)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def compare_matches(actual, expected)
|
|
158
|
+
Regexp.new(expected.to_s).match?(actual.to_s)
|
|
159
|
+
rescue RegexpError
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def normalize_value(value)
|
|
164
|
+
case value
|
|
165
|
+
when Symbol then value.to_s
|
|
166
|
+
when TrueClass then true
|
|
167
|
+
when FalseClass then false
|
|
168
|
+
when Numeric then value
|
|
169
|
+
else value.to_s
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def to_number(value)
|
|
174
|
+
case value
|
|
175
|
+
when Numeric then value
|
|
176
|
+
when String
|
|
177
|
+
if value.include?(".")
|
|
178
|
+
Float(value)
|
|
179
|
+
else
|
|
180
|
+
Integer(value)
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
rescue ArgumentError, TypeError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/subflag/rails.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "rails/client"
|
|
|
11
11
|
require_relative "rails/flag_accessor"
|
|
12
12
|
require_relative "rails/helpers"
|
|
13
13
|
require_relative "rails/railtie" if defined?(Rails::Railtie)
|
|
14
|
+
require_relative "rails/engine" if defined?(Rails::Engine)
|
|
14
15
|
|
|
15
16
|
# Test helpers are loaded separately: require "subflag/rails/test_helpers"
|
|
16
17
|
|
|
@@ -130,6 +131,7 @@ module Subflag
|
|
|
130
131
|
end
|
|
131
132
|
|
|
132
133
|
def build_active_record_provider
|
|
134
|
+
require_relative "rails/targeting"
|
|
133
135
|
require_relative "rails/backends/active_record_provider"
|
|
134
136
|
require_relative "rails/models/flag"
|
|
135
137
|
Backends::ActiveRecordProvider.new
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: subflag-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Subflag
|
|
@@ -193,12 +193,15 @@ files:
|
|
|
193
193
|
- lib/subflag/rails/client.rb
|
|
194
194
|
- lib/subflag/rails/configuration.rb
|
|
195
195
|
- lib/subflag/rails/context_builder.rb
|
|
196
|
+
- lib/subflag/rails/engine.rb
|
|
196
197
|
- lib/subflag/rails/evaluation_result.rb
|
|
197
198
|
- lib/subflag/rails/flag_accessor.rb
|
|
198
199
|
- lib/subflag/rails/helpers.rb
|
|
199
200
|
- lib/subflag/rails/models/flag.rb
|
|
200
201
|
- lib/subflag/rails/railtie.rb
|
|
201
202
|
- lib/subflag/rails/request_cache.rb
|
|
203
|
+
- lib/subflag/rails/targeting.rb
|
|
204
|
+
- lib/subflag/rails/targeting_engine.rb
|
|
202
205
|
- lib/subflag/rails/test_helpers.rb
|
|
203
206
|
- lib/subflag/rails/version.rb
|
|
204
207
|
homepage: https://subflag.com
|