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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +363 -0
- data/lib/generators/magick/install/install_generator.rb +19 -0
- data/lib/generators/magick/install/templates/README +25 -0
- data/lib/generators/magick/install/templates/magick.rb +32 -0
- data/lib/magick/adapters/base.rb +27 -0
- data/lib/magick/adapters/memory.rb +113 -0
- data/lib/magick/adapters/redis.rb +97 -0
- data/lib/magick/adapters/registry.rb +133 -0
- data/lib/magick/audit_log.rb +65 -0
- data/lib/magick/circuit_breaker.rb +65 -0
- data/lib/magick/config.rb +179 -0
- data/lib/magick/dsl.rb +80 -0
- data/lib/magick/errors.rb +9 -0
- data/lib/magick/export_import.rb +82 -0
- data/lib/magick/feature.rb +665 -0
- data/lib/magick/feature_dependency.rb +28 -0
- data/lib/magick/feature_variant.rb +17 -0
- data/lib/magick/performance_metrics.rb +76 -0
- data/lib/magick/rails/event_subscriber.rb +55 -0
- data/lib/magick/rails/events.rb +236 -0
- data/lib/magick/rails/railtie.rb +94 -0
- data/lib/magick/rails.rb +7 -0
- data/lib/magick/targeting/base.rb +11 -0
- data/lib/magick/targeting/complex.rb +27 -0
- data/lib/magick/targeting/custom_attribute.rb +35 -0
- data/lib/magick/targeting/date_range.rb +17 -0
- data/lib/magick/targeting/group.rb +15 -0
- data/lib/magick/targeting/ip_address.rb +22 -0
- data/lib/magick/targeting/percentage.rb +24 -0
- data/lib/magick/targeting/request_percentage.rb +15 -0
- data/lib/magick/targeting/role.rb +15 -0
- data/lib/magick/targeting/user.rb +15 -0
- data/lib/magick/testing_helpers.rb +45 -0
- data/lib/magick/version.rb +5 -0
- data/lib/magick/versioning.rb +98 -0
- data/lib/magick.rb +143 -0
- metadata +123 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Adapters
|
|
5
|
+
class Registry
|
|
6
|
+
CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'.freeze
|
|
7
|
+
|
|
8
|
+
def initialize(memory_adapter, redis_adapter = nil, circuit_breaker: nil, async: false)
|
|
9
|
+
@memory_adapter = memory_adapter
|
|
10
|
+
@redis_adapter = redis_adapter
|
|
11
|
+
@circuit_breaker = circuit_breaker || Magick::CircuitBreaker.new
|
|
12
|
+
@async = async
|
|
13
|
+
@subscriber_thread = nil
|
|
14
|
+
@subscriber = nil
|
|
15
|
+
# Only start Pub/Sub subscriber if Redis is available
|
|
16
|
+
# In memory-only mode, each process has isolated cache (no cross-process invalidation)
|
|
17
|
+
start_cache_invalidation_subscriber if redis_adapter
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get(feature_name, key)
|
|
21
|
+
# Try memory first (fastest) - no Redis calls needed thanks to Pub/Sub invalidation
|
|
22
|
+
value = memory_adapter.get(feature_name, key)
|
|
23
|
+
return value unless value.nil?
|
|
24
|
+
|
|
25
|
+
# Fall back to Redis if available
|
|
26
|
+
if redis_adapter
|
|
27
|
+
begin
|
|
28
|
+
value = redis_adapter.get(feature_name, key)
|
|
29
|
+
# Update memory cache if found in Redis
|
|
30
|
+
memory_adapter.set(feature_name, key, value) if value
|
|
31
|
+
return value
|
|
32
|
+
rescue AdapterError
|
|
33
|
+
# Redis failed, return nil
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set(feature_name, key, value)
|
|
42
|
+
# Update memory first (always synchronous)
|
|
43
|
+
memory_adapter.set(feature_name, key, value)
|
|
44
|
+
|
|
45
|
+
# Update Redis if available
|
|
46
|
+
if redis_adapter
|
|
47
|
+
update_redis = proc do
|
|
48
|
+
circuit_breaker.call do
|
|
49
|
+
redis_adapter.set(feature_name, key, value)
|
|
50
|
+
# Publish cache invalidation message to notify other processes
|
|
51
|
+
publish_cache_invalidation(feature_name)
|
|
52
|
+
end
|
|
53
|
+
rescue AdapterError => e
|
|
54
|
+
# Log error but don't fail - memory is updated
|
|
55
|
+
warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if @async && defined?(Thread)
|
|
59
|
+
Thread.new { update_redis.call }
|
|
60
|
+
else
|
|
61
|
+
update_redis.call
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delete(feature_name)
|
|
67
|
+
memory_adapter.delete(feature_name)
|
|
68
|
+
if redis_adapter
|
|
69
|
+
begin
|
|
70
|
+
redis_adapter.delete(feature_name)
|
|
71
|
+
# Publish cache invalidation message
|
|
72
|
+
publish_cache_invalidation(feature_name)
|
|
73
|
+
rescue AdapterError
|
|
74
|
+
# Continue even if Redis fails
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def exists?(feature_name)
|
|
80
|
+
memory_adapter.exists?(feature_name) || (redis_adapter&.exists?(feature_name) == true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def all_features
|
|
84
|
+
memory_features = memory_adapter.all_features
|
|
85
|
+
redis_features = redis_adapter&.all_features || []
|
|
86
|
+
(memory_features + redis_features).uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
attr_reader :memory_adapter, :redis_adapter, :circuit_breaker
|
|
92
|
+
|
|
93
|
+
# Publish cache invalidation message to Redis Pub/Sub
|
|
94
|
+
def publish_cache_invalidation(feature_name)
|
|
95
|
+
return unless redis_adapter
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
redis_client = redis_adapter.instance_variable_get(:@redis)
|
|
99
|
+
redis_client&.publish(CACHE_INVALIDATION_CHANNEL, feature_name.to_s)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
# Silently fail - cache invalidation is best effort
|
|
102
|
+
warn "Failed to publish cache invalidation: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Start a background thread to listen for cache invalidation messages
|
|
107
|
+
def start_cache_invalidation_subscriber
|
|
108
|
+
return unless redis_adapter && defined?(Thread)
|
|
109
|
+
|
|
110
|
+
@subscriber_thread = Thread.new do
|
|
111
|
+
begin
|
|
112
|
+
redis_client = redis_adapter.instance_variable_get(:@redis)
|
|
113
|
+
return unless redis_client
|
|
114
|
+
|
|
115
|
+
@subscriber = redis_client.dup
|
|
116
|
+
@subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
|
|
117
|
+
on.message do |_channel, feature_name|
|
|
118
|
+
# Invalidate memory cache for this feature
|
|
119
|
+
memory_adapter.delete(feature_name)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
# If subscription fails, log and retry after a delay
|
|
124
|
+
warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
125
|
+
sleep 5
|
|
126
|
+
retry
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
@subscriber_thread.abort_on_exception = false
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
class AuditLog
|
|
5
|
+
class Entry
|
|
6
|
+
attr_reader :feature_name, :action, :user_id, :timestamp, :changes, :metadata
|
|
7
|
+
|
|
8
|
+
def initialize(feature_name, action, user_id: nil, changes: {}, metadata: {})
|
|
9
|
+
@feature_name = feature_name.to_s
|
|
10
|
+
@action = action.to_s
|
|
11
|
+
@user_id = user_id
|
|
12
|
+
@timestamp = Time.now
|
|
13
|
+
@changes = changes
|
|
14
|
+
@metadata = metadata
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
{
|
|
19
|
+
feature_name: feature_name,
|
|
20
|
+
action: action,
|
|
21
|
+
user_id: user_id,
|
|
22
|
+
timestamp: timestamp.iso8601,
|
|
23
|
+
changes: changes,
|
|
24
|
+
metadata: metadata
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(adapter = nil)
|
|
30
|
+
@adapter = adapter || default_adapter
|
|
31
|
+
@logs = []
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def log(feature_name, action, user_id: nil, changes: {}, metadata: {})
|
|
36
|
+
entry = Entry.new(feature_name, action, user_id: user_id, changes: changes, metadata: metadata)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@logs << entry
|
|
39
|
+
@adapter.append(entry) if @adapter.respond_to?(:append)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Rails 8+ event
|
|
43
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
44
|
+
Magick::Rails::Events.audit_logged(feature_name, action: action, user_id: user_id, changes: changes, **metadata)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
entry
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def entries(feature_name: nil, limit: 100)
|
|
51
|
+
result = @logs
|
|
52
|
+
result = result.select { |e| e.feature_name == feature_name.to_s } if feature_name
|
|
53
|
+
result.last(limit)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def default_adapter
|
|
59
|
+
# Default to in-memory storage
|
|
60
|
+
Class.new do
|
|
61
|
+
def append(_entry); end
|
|
62
|
+
end.new
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
class CircuitBreaker
|
|
5
|
+
DEFAULT_FAILURE_THRESHOLD = 5
|
|
6
|
+
DEFAULT_TIMEOUT = 60
|
|
7
|
+
|
|
8
|
+
attr_reader :failure_count, :last_failure_time, :state
|
|
9
|
+
|
|
10
|
+
def initialize(failure_threshold: DEFAULT_FAILURE_THRESHOLD, timeout: DEFAULT_TIMEOUT)
|
|
11
|
+
@failure_threshold = failure_threshold
|
|
12
|
+
@timeout = timeout
|
|
13
|
+
@failure_count = 0
|
|
14
|
+
@last_failure_time = nil
|
|
15
|
+
@state = :closed
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
return false if open?
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
result = yield
|
|
24
|
+
record_success
|
|
25
|
+
result
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
record_failure
|
|
28
|
+
raise e
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def open?
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
if @state == :open
|
|
35
|
+
if Time.now.to_i - @last_failure_time.to_i > @timeout
|
|
36
|
+
@state = :half_open
|
|
37
|
+
@failure_count = 0
|
|
38
|
+
false
|
|
39
|
+
else
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def record_success
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@failure_count = 0
|
|
53
|
+
@state = :closed if @state == :half_open
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record_failure
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@failure_count += 1
|
|
60
|
+
@last_failure_time = Time.now
|
|
61
|
+
@state = :open if @failure_count >= @failure_threshold
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning
|
|
6
|
+
attr_accessor :warn_on_deprecated, :async_updates, :memory_ttl, :circuit_breaker_threshold
|
|
7
|
+
attr_accessor :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@warn_on_deprecated = false
|
|
11
|
+
@async_updates = false
|
|
12
|
+
@memory_ttl = 3600 # 1 hour
|
|
13
|
+
@circuit_breaker_threshold = 5
|
|
14
|
+
@circuit_breaker_timeout = 60
|
|
15
|
+
@redis_namespace = 'magick:features'
|
|
16
|
+
@environment = defined?(Rails) ? Rails.env.to_s : 'development'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# DSL methods for configuration
|
|
20
|
+
def adapter(type, **options, &block)
|
|
21
|
+
case type.to_sym
|
|
22
|
+
when :memory
|
|
23
|
+
configure_memory_adapter(**options)
|
|
24
|
+
when :redis
|
|
25
|
+
configure_redis_adapter(**options)
|
|
26
|
+
when :registry
|
|
27
|
+
if block_given?
|
|
28
|
+
instance_eval(&block)
|
|
29
|
+
configure_registry_adapter
|
|
30
|
+
else
|
|
31
|
+
configure_registry_adapter(**options)
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "Unknown adapter type: #{type}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def memory(**options)
|
|
39
|
+
configure_memory_adapter(**options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def redis(url: nil, namespace: nil, **options)
|
|
43
|
+
@redis_url = url if url
|
|
44
|
+
@redis_namespace = namespace if namespace
|
|
45
|
+
configure_redis_adapter(url: url, namespace: namespace, **options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def performance_metrics(enabled: true, **options)
|
|
49
|
+
if enabled
|
|
50
|
+
@performance_metrics = PerformanceMetrics.new
|
|
51
|
+
else
|
|
52
|
+
@performance_metrics = nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def audit_log(enabled: true, adapter: nil)
|
|
57
|
+
if enabled
|
|
58
|
+
@audit_log = adapter ? AuditLog.new(adapter) : AuditLog.new
|
|
59
|
+
else
|
|
60
|
+
@audit_log = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def versioning(enabled: true)
|
|
65
|
+
if enabled
|
|
66
|
+
@versioning = Versioning.new(adapter_registry || default_adapter_registry)
|
|
67
|
+
else
|
|
68
|
+
@versioning = nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def circuit_breaker(threshold: nil, timeout: nil)
|
|
73
|
+
@circuit_breaker_threshold = threshold if threshold
|
|
74
|
+
@circuit_breaker_timeout = timeout if timeout
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def async_updates(enabled: true)
|
|
78
|
+
@async_updates = enabled
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def memory_ttl(seconds)
|
|
82
|
+
@memory_ttl = seconds
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def warn_on_deprecated(enabled: true)
|
|
86
|
+
@warn_on_deprecated = enabled
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def environment(name)
|
|
90
|
+
@environment = name.to_s
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def apply!
|
|
94
|
+
# Apply configuration to Magick module
|
|
95
|
+
Magick.adapter_registry = adapter_registry if adapter_registry
|
|
96
|
+
Magick.performance_metrics = performance_metrics if performance_metrics
|
|
97
|
+
Magick.audit_log = audit_log if audit_log
|
|
98
|
+
Magick.versioning = versioning if versioning
|
|
99
|
+
Magick.warn_on_deprecated = warn_on_deprecated
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def configure_memory_adapter(ttl: nil)
|
|
105
|
+
ttl ||= @memory_ttl
|
|
106
|
+
adapter = Adapters::Memory.new
|
|
107
|
+
# Set default TTL by updating the adapter's default_ttl
|
|
108
|
+
adapter.instance_variable_set(:@default_ttl, ttl) if ttl
|
|
109
|
+
adapter
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def configure_redis_adapter(url: nil, namespace: nil, client: nil)
|
|
113
|
+
return nil unless defined?(Redis)
|
|
114
|
+
|
|
115
|
+
url ||= @redis_url
|
|
116
|
+
namespace ||= @redis_namespace
|
|
117
|
+
|
|
118
|
+
redis_client = client || begin
|
|
119
|
+
if url
|
|
120
|
+
::Redis.new(url: url)
|
|
121
|
+
else
|
|
122
|
+
::Redis.new
|
|
123
|
+
end
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return nil unless redis_client
|
|
129
|
+
|
|
130
|
+
adapter = Adapters::Redis.new(redis_client)
|
|
131
|
+
adapter.instance_variable_set(:@namespace, namespace) if namespace
|
|
132
|
+
adapter
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def configure_registry_adapter(memory: nil, redis: nil, async: nil, circuit_breaker: nil)
|
|
136
|
+
memory_adapter = memory || configure_memory_adapter
|
|
137
|
+
redis_adapter = redis || configure_redis_adapter
|
|
138
|
+
|
|
139
|
+
cb = circuit_breaker || CircuitBreaker.new(
|
|
140
|
+
failure_threshold: @circuit_breaker_threshold,
|
|
141
|
+
timeout: @circuit_breaker_timeout
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async_enabled = async.nil? ? @async_updates : async
|
|
145
|
+
|
|
146
|
+
@adapter_registry = Adapters::Registry.new(
|
|
147
|
+
memory_adapter,
|
|
148
|
+
redis_adapter,
|
|
149
|
+
circuit_breaker: cb,
|
|
150
|
+
async: async_enabled
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def default_adapter_registry
|
|
155
|
+
@default_adapter_registry ||= begin
|
|
156
|
+
memory_adapter = Adapters::Memory.new
|
|
157
|
+
redis_adapter = configure_redis_adapter
|
|
158
|
+
Adapters::Registry.new(memory_adapter, redis_adapter)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# DSL for configuration
|
|
164
|
+
module ConfigDSL
|
|
165
|
+
def self.configure(&block)
|
|
166
|
+
config = Config.new
|
|
167
|
+
config.instance_eval(&block)
|
|
168
|
+
config.apply!
|
|
169
|
+
config
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.load_from_file(file_path)
|
|
173
|
+
config = Config.new
|
|
174
|
+
config.instance_eval(File.read(file_path), file_path)
|
|
175
|
+
config.apply!
|
|
176
|
+
config
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/magick/dsl.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module DSL
|
|
5
|
+
# Feature definition DSL methods
|
|
6
|
+
def feature(name, **options)
|
|
7
|
+
Magick.register_feature(name, **options)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def boolean_feature(name, default: false, **options)
|
|
11
|
+
Magick.register_feature(name, type: :boolean, default_value: default, **options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def string_feature(name, default: '', **options)
|
|
15
|
+
Magick.register_feature(name, type: :string, default_value: default, **options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def number_feature(name, default: 0, **options)
|
|
19
|
+
Magick.register_feature(name, type: :number, default_value: default, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Targeting DSL methods
|
|
23
|
+
def enable_for_user(feature_name, user_id)
|
|
24
|
+
Magick[feature_name].enable_for_user(user_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enable_for_group(feature_name, group_name)
|
|
28
|
+
Magick[feature_name].enable_for_group(group_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enable_for_role(feature_name, role_name)
|
|
32
|
+
Magick[feature_name].enable_for_role(role_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def enable_percentage(feature_name, percentage, type: :users)
|
|
36
|
+
feature = Magick[feature_name]
|
|
37
|
+
case type
|
|
38
|
+
when :users
|
|
39
|
+
feature.enable_percentage_of_users(percentage)
|
|
40
|
+
when :requests
|
|
41
|
+
feature.enable_percentage_of_requests(percentage)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enable_for_date_range(feature_name, start_date, end_date)
|
|
46
|
+
Magick[feature_name].enable_for_date_range(start_date, end_date)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def enable_for_ip_addresses(feature_name, *ip_addresses)
|
|
50
|
+
Magick[feature_name].enable_for_ip_addresses(ip_addresses)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enable_for_custom_attribute(feature_name, attribute_name, values, operator: :equals)
|
|
54
|
+
Magick[feature_name].enable_for_custom_attribute(attribute_name, values, operator: operator)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def set_variants(feature_name, variants)
|
|
58
|
+
Magick[feature_name].set_variants(variants)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_dependency(feature_name, dependency_name)
|
|
62
|
+
Magick[feature_name].add_dependency(dependency_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Enable/disable features globally
|
|
66
|
+
def enable_feature(feature_name, user_id: nil)
|
|
67
|
+
Magick[feature_name].enable(user_id: user_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def disable_feature(feature_name, user_id: nil)
|
|
71
|
+
Magick[feature_name].disable(user_id: user_id)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Make DSL methods available at top level for config/features.rb and config/initializers/features.rb
|
|
77
|
+
# Include into Object so methods are available as instance methods on main (top-level context)
|
|
78
|
+
Object.class_eval do
|
|
79
|
+
include Magick::DSL unless included_modules.include?(Magick::DSL)
|
|
80
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Magick
|
|
6
|
+
class ExportImport
|
|
7
|
+
def self.export(features_hash)
|
|
8
|
+
result = features_hash.map do |name, feature|
|
|
9
|
+
feature.to_h
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Rails 8+ event
|
|
13
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
14
|
+
Magick::Rails::Events.exported(format: :hash, feature_count: result.length)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
result
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.export_json(features_hash)
|
|
21
|
+
result = JSON.pretty_generate(export(features_hash))
|
|
22
|
+
|
|
23
|
+
# Rails 8+ event
|
|
24
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
25
|
+
Magick::Rails::Events.exported(format: :json, feature_count: features_hash.length)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.import(data, adapter_registry)
|
|
32
|
+
features = {}
|
|
33
|
+
data = JSON.parse(data) if data.is_a?(String)
|
|
34
|
+
|
|
35
|
+
Array(data).each do |feature_data|
|
|
36
|
+
name = feature_data['name'] || feature_data[:name]
|
|
37
|
+
next unless name
|
|
38
|
+
|
|
39
|
+
feature = Feature.new(
|
|
40
|
+
name,
|
|
41
|
+
adapter_registry,
|
|
42
|
+
type: (feature_data['type'] || feature_data[:type] || :boolean).to_sym,
|
|
43
|
+
status: (feature_data['status'] || feature_data[:status] || :active).to_sym,
|
|
44
|
+
default_value: feature_data['default_value'] || feature_data[:default_value],
|
|
45
|
+
description: feature_data['description'] || feature_data[:description]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
feature.set_value(feature_data['value'] || feature_data[:value]) if feature_data['value'] || feature_data[:value]
|
|
49
|
+
|
|
50
|
+
# Import targeting
|
|
51
|
+
if feature_data['targeting'] || feature_data[:targeting]
|
|
52
|
+
targeting = feature_data['targeting'] || feature_data[:targeting]
|
|
53
|
+
targeting.each do |type, values|
|
|
54
|
+
Array(values).each do |value|
|
|
55
|
+
case type.to_sym
|
|
56
|
+
when :user
|
|
57
|
+
feature.enable_for_user(value)
|
|
58
|
+
when :group
|
|
59
|
+
feature.enable_for_group(value)
|
|
60
|
+
when :role
|
|
61
|
+
feature.enable_for_role(value)
|
|
62
|
+
when :percentage_users
|
|
63
|
+
feature.enable_percentage_of_users(value)
|
|
64
|
+
when :percentage_requests
|
|
65
|
+
feature.enable_percentage_of_requests(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
features[name.to_s] = feature
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Rails 8+ event
|
|
75
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
76
|
+
Magick::Rails::Events.imported(format: format, feature_count: features.length)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
features
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|