magick-feature-flags 0.7.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +363 -0
  4. data/lib/generators/magick/install/install_generator.rb +19 -0
  5. data/lib/generators/magick/install/templates/README +25 -0
  6. data/lib/generators/magick/install/templates/magick.rb +32 -0
  7. data/lib/magick/adapters/base.rb +27 -0
  8. data/lib/magick/adapters/memory.rb +113 -0
  9. data/lib/magick/adapters/redis.rb +97 -0
  10. data/lib/magick/adapters/registry.rb +133 -0
  11. data/lib/magick/audit_log.rb +65 -0
  12. data/lib/magick/circuit_breaker.rb +65 -0
  13. data/lib/magick/config.rb +179 -0
  14. data/lib/magick/dsl.rb +80 -0
  15. data/lib/magick/errors.rb +9 -0
  16. data/lib/magick/export_import.rb +82 -0
  17. data/lib/magick/feature.rb +665 -0
  18. data/lib/magick/feature_dependency.rb +28 -0
  19. data/lib/magick/feature_variant.rb +17 -0
  20. data/lib/magick/performance_metrics.rb +76 -0
  21. data/lib/magick/rails/event_subscriber.rb +55 -0
  22. data/lib/magick/rails/events.rb +236 -0
  23. data/lib/magick/rails/railtie.rb +94 -0
  24. data/lib/magick/rails.rb +7 -0
  25. data/lib/magick/targeting/base.rb +11 -0
  26. data/lib/magick/targeting/complex.rb +27 -0
  27. data/lib/magick/targeting/custom_attribute.rb +35 -0
  28. data/lib/magick/targeting/date_range.rb +17 -0
  29. data/lib/magick/targeting/group.rb +15 -0
  30. data/lib/magick/targeting/ip_address.rb +22 -0
  31. data/lib/magick/targeting/percentage.rb +24 -0
  32. data/lib/magick/targeting/request_percentage.rb +15 -0
  33. data/lib/magick/targeting/role.rb +15 -0
  34. data/lib/magick/targeting/user.rb +15 -0
  35. data/lib/magick/testing_helpers.rb +45 -0
  36. data/lib/magick/version.rb +5 -0
  37. data/lib/magick/versioning.rb +98 -0
  38. data/lib/magick.rb +143 -0
  39. metadata +123 -0
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ class PerformanceMetrics
5
+ class Metric
6
+ attr_reader :feature_name, :operation, :duration, :timestamp, :success
7
+
8
+ def initialize(feature_name, operation, duration, success: true)
9
+ @feature_name = feature_name.to_s
10
+ @operation = operation.to_s
11
+ @duration = duration
12
+ @timestamp = Time.now
13
+ @success = success
14
+ end
15
+
16
+ def to_h
17
+ {
18
+ feature_name: feature_name,
19
+ operation: operation,
20
+ duration: duration,
21
+ timestamp: timestamp.iso8601,
22
+ success: success
23
+ }
24
+ end
25
+ end
26
+
27
+ def initialize
28
+ @metrics = []
29
+ @mutex = Mutex.new
30
+ @usage_count = Hash.new(0)
31
+ end
32
+
33
+ def record(feature_name, operation, duration, success: true)
34
+ metric = Metric.new(feature_name, operation, duration, success: success)
35
+ @mutex.synchronize do
36
+ @metrics << metric
37
+ @usage_count[feature_name.to_s] += 1
38
+ # Keep only last 1000 metrics
39
+ @metrics.shift if @metrics.length > 1000
40
+ end
41
+
42
+ # Rails 8+ event for usage tracking
43
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
44
+ Magick::Rails::Events.usage_tracked(feature_name, operation: operation, duration: duration, success: success)
45
+ end
46
+
47
+ metric
48
+ end
49
+
50
+ def average_duration(feature_name: nil, operation: nil)
51
+ filtered = @metrics.select do |m|
52
+ (feature_name.nil? || m.feature_name == feature_name.to_s) &&
53
+ (operation.nil? || m.operation == operation.to_s) &&
54
+ m.success
55
+ end
56
+ return 0.0 if filtered.empty?
57
+
58
+ filtered.sum(&:duration) / filtered.length.to_f
59
+ end
60
+
61
+ def usage_count(feature_name)
62
+ @usage_count[feature_name.to_s] || 0
63
+ end
64
+
65
+ def most_used_features(limit: 10)
66
+ @usage_count.sort_by { |_name, count| -count }.first(limit).to_h
67
+ end
68
+
69
+ def clear!
70
+ @mutex.synchronize do
71
+ @metrics.clear
72
+ @usage_count.clear
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Rails
5
+ # Example event subscriber for Rails 8.1+ structured events
6
+ # Users can create custom subscribers to handle Magick events
7
+ class EventSubscriber
8
+ def initialize
9
+ @subscribed = false
10
+ end
11
+
12
+ # Subscribe to all Magick events
13
+ def subscribe_to_all
14
+ return unless Events.rails81?
15
+
16
+ Events::EVENTS.each_value do |event_name|
17
+ subscribe_to(event_name)
18
+ end
19
+ @subscribed = true
20
+ end
21
+
22
+ # Subscribe to a specific event
23
+ def subscribe_to(event_name, &block)
24
+ return unless Events.rails81?
25
+
26
+ full_event_name = Events::EVENTS[event_name] || event_name.to_s
27
+ Rails.event.subscribe(full_event_name, self)
28
+ end
29
+
30
+ # Implement the emit method required by Rails 8.1 event system
31
+ def emit(event)
32
+ # event is a hash with :name, :payload, :source_location, :tags, :context
33
+ handle_event(event)
34
+ end
35
+
36
+ private
37
+
38
+ def handle_event(event)
39
+ # Default handler - users can override
40
+ Rails.logger&.info "Magick Event: #{event[:name]} - #{event[:payload].inspect}"
41
+ end
42
+ end
43
+
44
+ # Default log subscriber for Magick events
45
+ class LogSubscriber
46
+ def emit(event)
47
+ payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ")
48
+ source_location = event[:source_location]
49
+ log = "[#{event[:name]}] #{payload}"
50
+ log += " at #{source_location[:filepath]}:#{source_location[:lineno]}" if source_location
51
+ Rails.logger&.info(log)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Rails
5
+ module Events
6
+ # Check if Rails 8.1+ structured events are available
7
+ def self.rails81?
8
+ defined?(Rails) && Rails.respond_to?(:event) && Rails.event.respond_to?(:notify)
9
+ end
10
+
11
+ # Event names (using Rails 8.1 structured event format)
12
+ EVENT_PREFIX = 'magick.feature_flag'
13
+
14
+ EVENTS = {
15
+ changed: "#{EVENT_PREFIX}.changed",
16
+ enabled: "#{EVENT_PREFIX}.enabled",
17
+ disabled: "#{EVENT_PREFIX}.disabled",
18
+ enabled_globally: "#{EVENT_PREFIX}.enabled_globally",
19
+ disabled_globally: "#{EVENT_PREFIX}.disabled_globally",
20
+ dependency_added: "#{EVENT_PREFIX}.dependency_added",
21
+ dependency_removed: "#{EVENT_PREFIX}.dependency_removed",
22
+ variant_set: "#{EVENT_PREFIX}.variant_set",
23
+ variant_selected: "#{EVENT_PREFIX}.variant_selected",
24
+ targeting_added: "#{EVENT_PREFIX}.targeting_added",
25
+ targeting_removed: "#{EVENT_PREFIX}.targeting_removed",
26
+ version_saved: "#{EVENT_PREFIX}.version_saved",
27
+ rollback: "#{EVENT_PREFIX}.rollback",
28
+ exported: "#{EVENT_PREFIX}.exported",
29
+ imported: "#{EVENT_PREFIX}.imported",
30
+ audit_logged: "#{EVENT_PREFIX}.audit_logged",
31
+ usage_tracked: "#{EVENT_PREFIX}.usage_tracked",
32
+ deprecated_warning: "#{EVENT_PREFIX}.deprecated_warning"
33
+ }.freeze
34
+
35
+ def self.notify(event_name, payload = {})
36
+ return unless rails81?
37
+
38
+ event_name_str = EVENTS[event_name] || event_name.to_s
39
+ Rails.event.notify(event_name_str, payload)
40
+ end
41
+
42
+ # Backward compatibility alias
43
+ def self.rails8?
44
+ rails81?
45
+ end
46
+
47
+ # Feature flag changed (value, status, etc.)
48
+ def self.feature_changed(feature_name, changes:, user_id: nil, **metadata)
49
+ notify(:changed, {
50
+ feature_name: feature_name.to_s,
51
+ changes: changes,
52
+ user_id: user_id,
53
+ timestamp: Time.now.iso8601,
54
+ **metadata
55
+ })
56
+ end
57
+
58
+ # Feature enabled
59
+ def self.feature_enabled(feature_name, context: {}, **metadata)
60
+ notify(:enabled, {
61
+ feature_name: feature_name.to_s,
62
+ context: context,
63
+ timestamp: Time.now.iso8601,
64
+ **metadata
65
+ })
66
+ end
67
+
68
+ # Feature disabled
69
+ def self.feature_disabled(feature_name, context: {}, **metadata)
70
+ notify(:disabled, {
71
+ feature_name: feature_name.to_s,
72
+ context: context,
73
+ timestamp: Time.now.iso8601,
74
+ **metadata
75
+ })
76
+ end
77
+
78
+ # Feature enabled globally (no targeting)
79
+ def self.feature_enabled_globally(feature_name, user_id: nil, **metadata)
80
+ notify(:enabled_globally, {
81
+ feature_name: feature_name.to_s,
82
+ user_id: user_id,
83
+ timestamp: Time.now.iso8601,
84
+ **metadata
85
+ })
86
+ end
87
+
88
+ # Feature disabled globally (no targeting)
89
+ def self.feature_disabled_globally(feature_name, user_id: nil, **metadata)
90
+ notify(:disabled_globally, {
91
+ feature_name: feature_name.to_s,
92
+ user_id: user_id,
93
+ timestamp: Time.now.iso8601,
94
+ **metadata
95
+ })
96
+ end
97
+
98
+ # Dependency added
99
+ def self.dependency_added(feature_name, dependency_name, **metadata)
100
+ notify(:dependency_added, {
101
+ feature_name: feature_name.to_s,
102
+ dependency_name: dependency_name.to_s,
103
+ timestamp: Time.now.iso8601,
104
+ **metadata
105
+ })
106
+ end
107
+
108
+ # Dependency removed
109
+ def self.dependency_removed(feature_name, dependency_name, **metadata)
110
+ notify(:dependency_removed, {
111
+ feature_name: feature_name.to_s,
112
+ dependency_name: dependency_name.to_s,
113
+ timestamp: Time.now.iso8601,
114
+ **metadata
115
+ })
116
+ end
117
+
118
+ # Variants set
119
+ def self.variant_set(feature_name, variants:, **metadata)
120
+ notify(:variant_set, {
121
+ feature_name: feature_name.to_s,
122
+ variants: variants.is_a?(Array) ? variants.map(&:to_h) : variants,
123
+ timestamp: Time.now.iso8601,
124
+ **metadata
125
+ })
126
+ end
127
+
128
+ # Variant selected
129
+ def self.variant_selected(feature_name, variant_name:, context: {}, **metadata)
130
+ notify(:variant_selected, {
131
+ feature_name: feature_name.to_s,
132
+ variant_name: variant_name.to_s,
133
+ context: context,
134
+ timestamp: Time.now.iso8601,
135
+ **metadata
136
+ })
137
+ end
138
+
139
+ # Targeting added
140
+ def self.targeting_added(feature_name, targeting_type:, targeting_value:, **metadata)
141
+ notify(:targeting_added, {
142
+ feature_name: feature_name.to_s,
143
+ targeting_type: targeting_type.to_s,
144
+ targeting_value: targeting_value,
145
+ timestamp: Time.now.iso8601,
146
+ **metadata
147
+ })
148
+ end
149
+
150
+ # Targeting removed
151
+ def self.targeting_removed(feature_name, targeting_type:, targeting_value: nil, **metadata)
152
+ notify(:targeting_removed, {
153
+ feature_name: feature_name.to_s,
154
+ targeting_type: targeting_type.to_s,
155
+ targeting_value: targeting_value,
156
+ timestamp: Time.now.iso8601,
157
+ **metadata
158
+ })
159
+ end
160
+
161
+ # Version saved
162
+ def self.version_saved(feature_name, version:, created_by: nil, **metadata)
163
+ notify(:version_saved, {
164
+ feature_name: feature_name.to_s,
165
+ version: version,
166
+ created_by: created_by,
167
+ timestamp: Time.now.iso8601,
168
+ **metadata
169
+ })
170
+ end
171
+
172
+ # Rollback performed
173
+ def self.rollback(feature_name, version:, **metadata)
174
+ notify(:rollback, {
175
+ feature_name: feature_name.to_s,
176
+ version: version,
177
+ timestamp: Time.now.iso8601,
178
+ **metadata
179
+ })
180
+ end
181
+
182
+ # Export performed
183
+ def self.exported(format:, feature_count:, **metadata)
184
+ notify(:exported, {
185
+ format: format.to_s,
186
+ feature_count: feature_count,
187
+ timestamp: Time.now.iso8601,
188
+ **metadata
189
+ })
190
+ end
191
+
192
+ # Import performed
193
+ def self.imported(format:, feature_count:, **metadata)
194
+ notify(:imported, {
195
+ format: format.to_s,
196
+ feature_count: feature_count,
197
+ timestamp: Time.now.iso8601,
198
+ **metadata
199
+ })
200
+ end
201
+
202
+ # Audit log entry created
203
+ def self.audit_logged(feature_name, action:, user_id: nil, changes: {}, **metadata)
204
+ notify(:audit_logged, {
205
+ feature_name: feature_name.to_s,
206
+ action: action.to_s,
207
+ user_id: user_id,
208
+ changes: changes,
209
+ timestamp: Time.now.iso8601,
210
+ **metadata
211
+ })
212
+ end
213
+
214
+ # Usage tracked
215
+ def self.usage_tracked(feature_name, operation:, duration:, success: true, **metadata)
216
+ notify(:usage_tracked, {
217
+ feature_name: feature_name.to_s,
218
+ operation: operation.to_s,
219
+ duration: duration,
220
+ success: success,
221
+ timestamp: Time.now.iso8601,
222
+ **metadata
223
+ })
224
+ end
225
+
226
+ # Deprecation warning
227
+ def self.deprecated_warning(feature_name, **metadata)
228
+ notify(:deprecated_warning, {
229
+ feature_name: feature_name.to_s,
230
+ timestamp: Time.now.iso8601,
231
+ **metadata
232
+ })
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(Rails)
4
+ require 'magick'
5
+ require 'magick/dsl'
6
+ require_relative 'events'
7
+
8
+ module Magick
9
+ module Rails
10
+ class Railtie < ::Rails::Railtie
11
+ # Make DSL available early so it works in config/initializers/features.rb
12
+ initializer 'magick.dsl', before: :load_config_initializers do
13
+ # Ensure DSL is available globally for initializers
14
+ Object.class_eval { include Magick::DSL } unless Object.included_modules.include?(Magick::DSL)
15
+ end
16
+
17
+ initializer 'magick.configure' do |app|
18
+ # Configure Magick with Rails-specific settings
19
+ Magick.configure do |config|
20
+ # Use Redis if available, otherwise fall back to memory
21
+ if defined?(Redis)
22
+ begin
23
+ redis_url = app.config.respond_to?(:redis_url) ? app.config.redis_url : nil
24
+ redis_client = redis_url ? ::Redis.new(url: redis_url) : ::Redis.new
25
+ memory_adapter = Adapters::Memory.new
26
+ redis_adapter = Adapters::Redis.new(redis_client)
27
+ config.adapter_registry = Adapters::Registry.new(memory_adapter, redis_adapter)
28
+ rescue StandardError => e
29
+ Rails.logger&.warn "Magick: Failed to initialize Redis adapter: #{e.message}. Using memory-only adapter."
30
+ end
31
+ end
32
+ end
33
+
34
+ # Load features from DSL file if it exists
35
+ # Supports both config/features.rb and config/initializers/features.rb
36
+ config.after_initialize do
37
+ # Try config/features.rb first (recommended location)
38
+ features_file = Rails.root.join('config', 'features.rb')
39
+ if File.exist?(features_file)
40
+ load features_file
41
+ else
42
+ # Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
43
+ initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
44
+ if File.exist?(initializer_file)
45
+ # Only load if not already loaded (Rails may have already loaded it)
46
+ load initializer_file unless defined?(Magick::Rails::FeaturesLoaded)
47
+ end
48
+ end
49
+ Magick::Rails.const_set(:FeaturesLoaded, true) rescue nil
50
+ end
51
+ end
52
+
53
+ # Preload features in request store
54
+ config.to_prepare do
55
+ if defined?(RequestStore)
56
+ RequestStore.store[:magick_features] ||= {}
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # Request store integration
63
+ module RequestStoreIntegration
64
+ def self.included(base)
65
+ base.extend(ClassMethods)
66
+ end
67
+
68
+ module ClassMethods
69
+ def enabled?(feature_name, context = {})
70
+ # Check request store cache first
71
+ if defined?(RequestStore)
72
+ cache_key = "#{feature_name}:#{context.hash}"
73
+ cached = RequestStore.store[:magick_features]&.[](cache_key)
74
+ return cached unless cached.nil?
75
+ end
76
+
77
+ # Check feature
78
+ result = super(feature_name, context)
79
+
80
+ # Cache in request store
81
+ if defined?(RequestStore)
82
+ RequestStore.store[:magick_features] ||= {}
83
+ RequestStore.store[:magick_features][cache_key] = result
84
+ end
85
+
86
+ result
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ # Extend Magick module with request store integration
93
+ Magick.extend(Magick::Rails::RequestStoreIntegration)
94
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails integration is now in lib/magick/rails/railtie.rb
4
+ # This file is kept for backward compatibility
5
+ if defined?(Rails)
6
+ require_relative 'rails/railtie'
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class Base
6
+ def matches?(context)
7
+ raise NotImplementedError, "#{self.class} must implement #matches?"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class Complex < Base
6
+ def initialize(conditions, operator: :and)
7
+ @conditions = Array(conditions)
8
+ @operator = operator.to_sym
9
+ end
10
+
11
+ def matches?(context)
12
+ return false if @conditions.empty?
13
+
14
+ results = @conditions.map { |condition| condition.matches?(context) }
15
+
16
+ case @operator
17
+ when :and, :all
18
+ results.all?
19
+ when :or, :any
20
+ results.any?
21
+ else
22
+ false
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class CustomAttribute < Base
6
+ def initialize(attribute_name, values, operator: :equals)
7
+ @attribute_name = attribute_name.to_sym
8
+ @values = Array(values)
9
+ @operator = operator.to_sym
10
+ end
11
+
12
+ def matches?(context)
13
+ context_value = context[@attribute_name] || context[@attribute_name.to_s]
14
+ return false if context_value.nil?
15
+
16
+ case @operator
17
+ when :equals, :eq
18
+ @values.include?(context_value.to_s)
19
+ when :not_equals, :ne
20
+ !@values.include?(context_value.to_s)
21
+ when :in
22
+ @values.include?(context_value.to_s)
23
+ when :not_in
24
+ !@values.include?(context_value.to_s)
25
+ when :greater_than, :gt
26
+ context_value.to_f > @values.first.to_f
27
+ when :less_than, :lt
28
+ context_value.to_f < @values.first.to_f
29
+ else
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class DateRange < Base
6
+ def initialize(start_date, end_date)
7
+ @start_date = start_date.is_a?(String) ? Time.parse(start_date) : start_date
8
+ @end_date = end_date.is_a?(String) ? Time.parse(end_date) : end_date
9
+ end
10
+
11
+ def matches?(_context)
12
+ now = Time.now
13
+ now >= @start_date && now <= @end_date
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class Group < Base
6
+ def initialize(group_name)
7
+ @group_name = group_name.to_s
8
+ end
9
+
10
+ def matches?(context)
11
+ context[:group]&.to_s == @group_name
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module Magick
6
+ module Targeting
7
+ class IpAddress < Base
8
+ def initialize(ip_addresses)
9
+ @ip_addresses = Array(ip_addresses).map { |ip| IPAddr.new(ip) }
10
+ end
11
+
12
+ def matches?(context)
13
+ return false unless context[:ip_address]
14
+
15
+ client_ip = IPAddr.new(context[:ip_address])
16
+ @ip_addresses.any? { |ip| ip.include?(client_ip) }
17
+ rescue IPAddr::InvalidAddressError
18
+ false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Magick
6
+ module Targeting
7
+ class Percentage < Base
8
+ def initialize(percentage, feature_name, user_id = nil)
9
+ @percentage = percentage.to_f
10
+ @feature_name = feature_name.to_s
11
+ @user_id = user_id&.to_s
12
+ end
13
+
14
+ def matches?(context)
15
+ user_id = (@user_id || context[:user_id])&.to_s
16
+ return false unless user_id
17
+
18
+ hash = Digest::MD5.hexdigest("#{@feature_name}:#{user_id}")
19
+ hash_value = hash[0..7].to_i(16)
20
+ (hash_value % 100) < @percentage
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class RequestPercentage < Base
6
+ def initialize(percentage)
7
+ @percentage = percentage.to_f
8
+ end
9
+
10
+ def matches?(_context)
11
+ rand(100) < @percentage
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class Role < Base
6
+ def initialize(role_name)
7
+ @role_name = role_name.to_s
8
+ end
9
+
10
+ def matches?(context)
11
+ context[:role]&.to_s == @role_name
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Targeting
5
+ class User < Base
6
+ def initialize(user_id)
7
+ @user_id = user_id.to_s
8
+ end
9
+
10
+ def matches?(context)
11
+ context[:user_id]&.to_s == @user_id
12
+ end
13
+ end
14
+ end
15
+ end