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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da40f567a605d55076aaeabed1edd404bb41ba2a1703c56595e42611ebf4aaa4
4
- data.tar.gz: 39df667c30500ebf560e90694213f05b479d60f488575c7f2503409bc640222d
3
+ metadata.gz: bd3dfb04635e013ed8a9da2282150acf511c8704aace3c439a06f7e5dd0ac24a
4
+ data.tar.gz: adc804044699b6cea75d8b44fc742d08f2a96b24092a8e66d19b419f41c45fa1
5
5
  SHA512:
6
- metadata.gz: 0bb2499ecf945fb2586918f5c3e57cdda5e6ce2f143eb4b6d1d674370412ccee6f6e8fe5d992705ac4b0b28de88465ebe7e140fad1a2c71f691982ebe363c4ce
7
- data.tar.gz: f255c179b502557624bfcfc81bc06743c13898bef9d7b5225e4519e097fbdfc7b88af2b2729088883747cb3a123f1a51fff521618527e68e7e77ca494212be32
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 | The flag value as a string |
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 create_migration
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
- # Perfect for teams who want self-hosted feature flags without external dependencies.
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
- # # Create a flag
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
- # # Use it
25
- # subflag_value(:max_projects, default: 3) # => 100
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
- value = flag.typed_value(expected_type)
69
- resolution(value, reason: :static, variant: "default")
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
- # @example Create a boolean flag
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 Create an integer flag
17
+ # @example Flag with targeting rules (internal team gets different value)
15
18
  # Subflag::Rails::Flag.create!(
16
- # key: "max-projects",
17
- # value: "100",
18
- # value_type: "integer"
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 Create a JSON object flag
36
+ # @example Progressive rollout (first match wins)
22
37
  # Subflag::Rails::Flag.create!(
23
- # key: "feature-limits",
24
- # value: '{"max_items": 10, "max_users": 5}',
25
- # value_type: "object"
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
- # Get the flag value cast to its declared type
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(value)
97
+ ActiveModel::Type::Boolean.new.cast(raw_value)
51
98
  when "string"
52
- value.to_s
99
+ raw_value.to_s
53
100
  when "integer"
54
- value.to_i
101
+ raw_value.to_i
55
102
  when "float", "number"
56
- value.to_f
103
+ raw_value.to_f
57
104
  when "object"
58
- value.is_a?(Hash) ? value : JSON.parse(value)
105
+ raw_value.is_a?(Hash) ? raw_value : JSON.parse(raw_value)
59
106
  else
60
- value
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  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.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