toggly 0.1.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/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +34 -0
- data/lib/toggly/client.rb +287 -0
- data/lib/toggly/config.rb +139 -0
- data/lib/toggly/context.rb +149 -0
- data/lib/toggly/definitions_provider.rb +288 -0
- data/lib/toggly/errors.rb +56 -0
- data/lib/toggly/evaluation_engine.rb +136 -0
- data/lib/toggly/evaluators/always_off.rb +22 -0
- data/lib/toggly/evaluators/always_on.rb +22 -0
- data/lib/toggly/evaluators/base.rb +55 -0
- data/lib/toggly/evaluators/contextual_targeting.rb +116 -0
- data/lib/toggly/evaluators/percentage.rb +72 -0
- data/lib/toggly/evaluators/targeting.rb +51 -0
- data/lib/toggly/evaluators/time_window.rb +53 -0
- data/lib/toggly/feature_definition.rb +188 -0
- data/lib/toggly/registry.rb +86 -0
- data/lib/toggly/snapshot_providers/base.rb +67 -0
- data/lib/toggly/snapshot_providers/file.rb +95 -0
- data/lib/toggly/snapshot_providers/memory.rb +59 -0
- data/lib/toggly/version.rb +5 -0
- data/lib/toggly.rb +94 -0
- metadata +73 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for percentage-based rollouts.
|
|
6
|
+
#
|
|
7
|
+
# Uses FNV-1a hash for consistent bucketing based on
|
|
8
|
+
# feature key and user identity.
|
|
9
|
+
class Percentage < Base
|
|
10
|
+
# FNV-1a hash constants (32-bit)
|
|
11
|
+
FNV_PRIME = 0x01000193
|
|
12
|
+
FNV_OFFSET_BASIS = 0x811c9dc5
|
|
13
|
+
|
|
14
|
+
def self.type
|
|
15
|
+
"percentage"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Evaluate percentage rollout
|
|
19
|
+
#
|
|
20
|
+
# @param rule [Hash] Rule with "percentage" or "value" key (0-100)
|
|
21
|
+
# @param context [Context] Evaluation context with identity
|
|
22
|
+
# @param feature_key [String] The feature key
|
|
23
|
+
# @return [Boolean] True if user falls within percentage
|
|
24
|
+
def evaluate(rule, context, feature_key: nil)
|
|
25
|
+
percentage = rule_value(rule, "percentage") ||
|
|
26
|
+
rule_value(rule, "value") ||
|
|
27
|
+
0
|
|
28
|
+
|
|
29
|
+
percentage = percentage.to_f
|
|
30
|
+
|
|
31
|
+
# 0% always off, 100% always on
|
|
32
|
+
return false if percentage <= 0
|
|
33
|
+
return true if percentage >= 100
|
|
34
|
+
|
|
35
|
+
# Need identity for percentage rollouts
|
|
36
|
+
return false unless context&.identity?
|
|
37
|
+
|
|
38
|
+
# Calculate bucket using FNV-1a hash
|
|
39
|
+
bucket_key = "#{feature_key}:#{context.identity}"
|
|
40
|
+
bucket = calculate_bucket(bucket_key)
|
|
41
|
+
|
|
42
|
+
bucket < percentage
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Calculate bucket (0-100) using FNV-1a hash
|
|
48
|
+
#
|
|
49
|
+
# @param key [String] The key to hash
|
|
50
|
+
# @return [Float] Bucket value 0-100
|
|
51
|
+
def calculate_bucket(key)
|
|
52
|
+
hash = fnv1a_hash(key)
|
|
53
|
+
(hash % 10_000) / 100.0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# FNV-1a hash implementation
|
|
57
|
+
#
|
|
58
|
+
# @param data [String] Data to hash
|
|
59
|
+
# @return [Integer] 32-bit hash value
|
|
60
|
+
def fnv1a_hash(data)
|
|
61
|
+
hash = FNV_OFFSET_BASIS
|
|
62
|
+
|
|
63
|
+
data.each_byte do |byte|
|
|
64
|
+
hash ^= byte
|
|
65
|
+
hash = (hash * FNV_PRIME) & 0xFFFFFFFF
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
hash
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for user/group targeting rules.
|
|
6
|
+
#
|
|
7
|
+
# Supports targeting by:
|
|
8
|
+
# - Specific user identities
|
|
9
|
+
# - Group membership
|
|
10
|
+
class Targeting < Base
|
|
11
|
+
def self.type
|
|
12
|
+
"targeting"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Evaluate targeting rule
|
|
16
|
+
#
|
|
17
|
+
# @param rule [Hash] Rule with "users" and/or "groups" arrays
|
|
18
|
+
# @param context [Context] Evaluation context
|
|
19
|
+
# @param feature_key [String] The feature key
|
|
20
|
+
# @return [Boolean, nil] True if matched, false if excluded, nil to continue
|
|
21
|
+
def evaluate(rule, context, feature_key: nil)
|
|
22
|
+
return nil unless context
|
|
23
|
+
|
|
24
|
+
# Check user targeting
|
|
25
|
+
users = Array(rule_value(rule, "users"))
|
|
26
|
+
excluded_users = Array(rule_value(rule, "excludedUsers") || rule_value(rule, "excluded_users"))
|
|
27
|
+
|
|
28
|
+
if context.identity?
|
|
29
|
+
# Check exclusion first
|
|
30
|
+
return false if excluded_users.any? { |u| u.to_s == context.identity }
|
|
31
|
+
|
|
32
|
+
# Check inclusion
|
|
33
|
+
return true if users.any? { |u| u.to_s == context.identity }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check group targeting
|
|
37
|
+
groups = Array(rule_value(rule, "groups"))
|
|
38
|
+
excluded_groups = Array(rule_value(rule, "excludedGroups") || rule_value(rule, "excluded_groups"))
|
|
39
|
+
|
|
40
|
+
# Check group exclusion
|
|
41
|
+
return false if excluded_groups.any? { |g| context.in_group?(g) }
|
|
42
|
+
|
|
43
|
+
# Check group inclusion
|
|
44
|
+
return true if groups.any? { |g| context.in_group?(g) }
|
|
45
|
+
|
|
46
|
+
# No match, continue evaluation
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for time-based feature windows.
|
|
6
|
+
#
|
|
7
|
+
# Enables features within a specific time range.
|
|
8
|
+
class TimeWindow < Base
|
|
9
|
+
def self.type
|
|
10
|
+
"time_window"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Evaluate time window rule
|
|
14
|
+
#
|
|
15
|
+
# @param rule [Hash] Rule with "startTime"/"start_time" and/or "endTime"/"end_time"
|
|
16
|
+
# @param context [Context] Evaluation context (ignored)
|
|
17
|
+
# @param feature_key [String] The feature key
|
|
18
|
+
# @return [Boolean] True if current time is within window
|
|
19
|
+
def evaluate(rule, _context, feature_key: nil)
|
|
20
|
+
now = Time.now.utc
|
|
21
|
+
|
|
22
|
+
start_time = parse_time(
|
|
23
|
+
rule_value(rule, "startTime") || rule_value(rule, "start_time")
|
|
24
|
+
)
|
|
25
|
+
end_time = parse_time(
|
|
26
|
+
rule_value(rule, "endTime") || rule_value(rule, "end_time")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# No time constraints means always valid
|
|
30
|
+
return true if start_time.nil? && end_time.nil?
|
|
31
|
+
|
|
32
|
+
# Check start time
|
|
33
|
+
return false if start_time && now < start_time
|
|
34
|
+
|
|
35
|
+
# Check end time
|
|
36
|
+
return false if end_time && now > end_time
|
|
37
|
+
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_time(value)
|
|
44
|
+
return nil if value.nil?
|
|
45
|
+
return value if value.is_a?(Time)
|
|
46
|
+
|
|
47
|
+
Time.parse(value.to_s).utc
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Represents a feature flag definition.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# definition = Toggly::FeatureDefinition.new(
|
|
8
|
+
# feature_key: "dark-mode",
|
|
9
|
+
# feature_type: "Release",
|
|
10
|
+
# enabled: true,
|
|
11
|
+
# rules: [{ "type" => "percentage", "value" => 50 }]
|
|
12
|
+
# )
|
|
13
|
+
class FeatureDefinition
|
|
14
|
+
# @return [String] Unique feature key
|
|
15
|
+
attr_reader :feature_key
|
|
16
|
+
|
|
17
|
+
# @return [String] Feature type (Release, Experiment, Ops, Permission)
|
|
18
|
+
attr_reader :feature_type
|
|
19
|
+
|
|
20
|
+
# @return [Boolean] Whether the feature is globally enabled
|
|
21
|
+
attr_reader :enabled
|
|
22
|
+
|
|
23
|
+
# @return [Array<Hash>] Evaluation rules
|
|
24
|
+
attr_reader :rules
|
|
25
|
+
|
|
26
|
+
# @return [Hash] Additional metadata
|
|
27
|
+
attr_reader :metadata
|
|
28
|
+
|
|
29
|
+
# @return [Time, nil] When the feature was created
|
|
30
|
+
attr_reader :created_at
|
|
31
|
+
|
|
32
|
+
# @return [Time, nil] When the feature was last updated
|
|
33
|
+
attr_reader :updated_at
|
|
34
|
+
|
|
35
|
+
# @return [String, nil] Feature description
|
|
36
|
+
attr_reader :description
|
|
37
|
+
|
|
38
|
+
# Feature types
|
|
39
|
+
TYPES = %w[Release Experiment Ops Permission].freeze
|
|
40
|
+
|
|
41
|
+
def initialize(
|
|
42
|
+
feature_key:,
|
|
43
|
+
feature_type: "Release",
|
|
44
|
+
enabled: false,
|
|
45
|
+
rules: [],
|
|
46
|
+
metadata: {},
|
|
47
|
+
description: nil,
|
|
48
|
+
created_at: nil,
|
|
49
|
+
updated_at: nil
|
|
50
|
+
)
|
|
51
|
+
@feature_key = feature_key.to_s
|
|
52
|
+
@feature_type = validate_type(feature_type)
|
|
53
|
+
@enabled = enabled ? true : false
|
|
54
|
+
@rules = Array(rules)
|
|
55
|
+
@metadata = metadata || {}
|
|
56
|
+
@description = description
|
|
57
|
+
@created_at = parse_time(created_at)
|
|
58
|
+
@updated_at = parse_time(updated_at)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Create from a hash (e.g., from JSON)
|
|
62
|
+
#
|
|
63
|
+
# @param hash [Hash] Feature definition hash
|
|
64
|
+
# @return [FeatureDefinition]
|
|
65
|
+
def self.from_hash(hash)
|
|
66
|
+
hash = symbolize_keys(hash)
|
|
67
|
+
|
|
68
|
+
# Map API "filters" to internal "rules" format
|
|
69
|
+
rules = hash[:rules] || hash[:filters] || []
|
|
70
|
+
|
|
71
|
+
# In Toggly's model, features are enabled based on filter evaluation.
|
|
72
|
+
# If the API doesn't send "enabled", derive it from whether filters exist.
|
|
73
|
+
enabled = if hash.key?(:enabled)
|
|
74
|
+
hash[:enabled]
|
|
75
|
+
else
|
|
76
|
+
!rules.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
new(
|
|
80
|
+
feature_key: hash[:featureKey] || hash[:feature_key],
|
|
81
|
+
feature_type: hash[:featureType] || hash[:feature_type] || "Release",
|
|
82
|
+
enabled: enabled,
|
|
83
|
+
rules: rules,
|
|
84
|
+
metadata: hash[:metadata] || {},
|
|
85
|
+
description: hash[:description],
|
|
86
|
+
created_at: hash[:createdAt] || hash[:created_at],
|
|
87
|
+
updated_at: hash[:updatedAt] || hash[:updated_at]
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Convert to hash for serialization
|
|
92
|
+
#
|
|
93
|
+
# @return [Hash]
|
|
94
|
+
def to_h
|
|
95
|
+
{
|
|
96
|
+
feature_key: @feature_key,
|
|
97
|
+
feature_type: @feature_type,
|
|
98
|
+
enabled: @enabled,
|
|
99
|
+
rules: @rules,
|
|
100
|
+
metadata: @metadata,
|
|
101
|
+
description: @description,
|
|
102
|
+
created_at: @created_at&.iso8601,
|
|
103
|
+
updated_at: @updated_at&.iso8601
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check if feature has rules
|
|
108
|
+
#
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def rules?
|
|
111
|
+
!@rules.empty?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if feature is a release toggle
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def release?
|
|
118
|
+
@feature_type == "Release"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Check if feature is an experiment
|
|
122
|
+
#
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def experiment?
|
|
125
|
+
@feature_type == "Experiment"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Check if feature is an ops toggle
|
|
129
|
+
#
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def ops?
|
|
132
|
+
@feature_type == "Ops"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if feature is a permission toggle
|
|
136
|
+
#
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def permission?
|
|
139
|
+
@feature_type == "Permission"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Equality check
|
|
143
|
+
#
|
|
144
|
+
# @param other [FeatureDefinition]
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def ==(other)
|
|
147
|
+
return false unless other.is_a?(FeatureDefinition)
|
|
148
|
+
|
|
149
|
+
@feature_key == other.feature_key &&
|
|
150
|
+
@feature_type == other.feature_type &&
|
|
151
|
+
@enabled == other.enabled &&
|
|
152
|
+
@rules == other.rules
|
|
153
|
+
end
|
|
154
|
+
alias eql? ==
|
|
155
|
+
|
|
156
|
+
# Hash code for use in collections
|
|
157
|
+
#
|
|
158
|
+
# @return [Integer]
|
|
159
|
+
def hash
|
|
160
|
+
[@feature_key, @feature_type, @enabled, @rules].hash
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def validate_type(type)
|
|
166
|
+
type_str = type.to_s
|
|
167
|
+
return type_str if TYPES.include?(type_str)
|
|
168
|
+
|
|
169
|
+
"Release"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def parse_time(value)
|
|
173
|
+
return nil if value.nil?
|
|
174
|
+
return value if value.is_a?(Time)
|
|
175
|
+
|
|
176
|
+
Time.parse(value.to_s)
|
|
177
|
+
rescue ArgumentError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.symbolize_keys(hash)
|
|
182
|
+
return hash unless hash.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
185
|
+
end
|
|
186
|
+
private_class_method :symbolize_keys
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Registry for evaluators.
|
|
5
|
+
#
|
|
6
|
+
# Manages the collection of evaluators and dispatches
|
|
7
|
+
# evaluation requests to the appropriate handler.
|
|
8
|
+
class Registry
|
|
9
|
+
def initialize
|
|
10
|
+
@evaluators = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
register_defaults
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Register an evaluator
|
|
16
|
+
#
|
|
17
|
+
# @param evaluator [Evaluators::Base] The evaluator instance
|
|
18
|
+
# @return [self]
|
|
19
|
+
def register(evaluator)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@evaluators[evaluator.class.type.to_s.downcase] = evaluator
|
|
22
|
+
end
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get an evaluator by type
|
|
27
|
+
#
|
|
28
|
+
# @param type [String] The evaluator type
|
|
29
|
+
# @return [Evaluators::Base, nil]
|
|
30
|
+
def get(type)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@evaluators[type.to_s.downcase]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if an evaluator exists for a type
|
|
37
|
+
#
|
|
38
|
+
# @param type [String] The evaluator type
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def registered?(type)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@evaluators.key?(type.to_s.downcase)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# List all registered evaluator types
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<String>]
|
|
49
|
+
def types
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@evaluators.keys
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Unregister an evaluator
|
|
56
|
+
#
|
|
57
|
+
# @param type [String] The evaluator type
|
|
58
|
+
# @return [Evaluators::Base, nil] The removed evaluator
|
|
59
|
+
def unregister(type)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
@evaluators.delete(type.to_s.downcase)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Clear all evaluators
|
|
66
|
+
#
|
|
67
|
+
# @return [self]
|
|
68
|
+
def clear
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
@evaluators.clear
|
|
71
|
+
end
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def register_defaults
|
|
78
|
+
register(Evaluators::AlwaysOn.new)
|
|
79
|
+
register(Evaluators::AlwaysOff.new)
|
|
80
|
+
register(Evaluators::Percentage.new)
|
|
81
|
+
register(Evaluators::Targeting.new)
|
|
82
|
+
register(Evaluators::TimeWindow.new)
|
|
83
|
+
register(Evaluators::ContextualTargeting.new)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module SnapshotProviders
|
|
5
|
+
# Base class for snapshot providers.
|
|
6
|
+
#
|
|
7
|
+
# Snapshot providers persist feature definitions for
|
|
8
|
+
# offline access and faster startup.
|
|
9
|
+
class Base
|
|
10
|
+
# Save definitions snapshot
|
|
11
|
+
#
|
|
12
|
+
# @param definitions [Hash<String, FeatureDefinition>] Definitions to save
|
|
13
|
+
# @param metadata [Hash] Optional metadata (e.g., version, timestamp)
|
|
14
|
+
# @raise [NotImplementedError]
|
|
15
|
+
def save(definitions, metadata = {})
|
|
16
|
+
raise NotImplementedError, "Subclass must implement #save"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Load definitions snapshot
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash, nil] Hash with :definitions and :metadata, or nil if not found
|
|
22
|
+
# @raise [NotImplementedError]
|
|
23
|
+
def load
|
|
24
|
+
raise NotImplementedError, "Subclass must implement #load"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Clear the snapshot
|
|
28
|
+
#
|
|
29
|
+
# @raise [NotImplementedError]
|
|
30
|
+
def clear
|
|
31
|
+
raise NotImplementedError, "Subclass must implement #clear"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if a snapshot exists
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def exists?
|
|
38
|
+
!load.nil?
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
# Serialize definitions to a storable format
|
|
46
|
+
#
|
|
47
|
+
# @param definitions [Hash<String, FeatureDefinition>] Definitions
|
|
48
|
+
# @return [Array<Hash>]
|
|
49
|
+
def serialize_definitions(definitions)
|
|
50
|
+
definitions.values.map(&:to_h)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Deserialize definitions from stored format
|
|
54
|
+
#
|
|
55
|
+
# @param data [Array<Hash>] Serialized definitions
|
|
56
|
+
# @return [Hash<String, FeatureDefinition>]
|
|
57
|
+
def deserialize_definitions(data)
|
|
58
|
+
return {} unless data.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
data.each_with_object({}) do |item, hash|
|
|
61
|
+
definition = FeatureDefinition.from_hash(item)
|
|
62
|
+
hash[definition.feature_key] = definition
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Toggly
|
|
7
|
+
module SnapshotProviders
|
|
8
|
+
# File-based snapshot provider.
|
|
9
|
+
#
|
|
10
|
+
# Persists feature definitions to a JSON file.
|
|
11
|
+
class File < Base
|
|
12
|
+
# @return [String] Path to the snapshot file
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
# @param path [String] Path to the snapshot file
|
|
16
|
+
def initialize(path:)
|
|
17
|
+
super()
|
|
18
|
+
@path = path
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Save definitions to file
|
|
23
|
+
#
|
|
24
|
+
# @param definitions [Hash<String, FeatureDefinition>] Definitions
|
|
25
|
+
# @param metadata [Hash] Optional metadata
|
|
26
|
+
def save(definitions, metadata = {})
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
ensure_directory_exists
|
|
29
|
+
|
|
30
|
+
data = {
|
|
31
|
+
"definitions" => serialize_definitions(definitions),
|
|
32
|
+
"metadata" => metadata.merge("saved_at" => Time.now.utc.iso8601)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Write to temp file first, then rename (atomic operation)
|
|
36
|
+
temp_path = "#{@path}.tmp"
|
|
37
|
+
::File.write(temp_path, JSON.pretty_generate(data))
|
|
38
|
+
::File.rename(temp_path, @path)
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
raise SnapshotError, "Failed to save snapshot: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Load definitions from file
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash, nil] Hash with :definitions and :metadata
|
|
47
|
+
def load
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
return nil unless ::File.exist?(@path)
|
|
50
|
+
|
|
51
|
+
content = ::File.read(@path)
|
|
52
|
+
data = JSON.parse(content)
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
definitions: deserialize_definitions(data["definitions"]),
|
|
56
|
+
metadata: symbolize_keys(data["metadata"] || {})
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
rescue JSON::ParserError => e
|
|
60
|
+
raise SnapshotError, "Failed to parse snapshot: #{e.message}"
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
raise SnapshotError, "Failed to load snapshot: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Clear the snapshot file
|
|
66
|
+
def clear
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
FileUtils.rm_f(@path)
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
raise SnapshotError, "Failed to clear snapshot: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if snapshot file exists
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def exists?
|
|
78
|
+
::File.exist?(@path)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def ensure_directory_exists
|
|
84
|
+
dir = ::File.dirname(@path)
|
|
85
|
+
FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def symbolize_keys(hash)
|
|
89
|
+
return {} unless hash.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
hash.transform_keys(&:to_sym)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module SnapshotProviders
|
|
5
|
+
# In-memory snapshot provider.
|
|
6
|
+
#
|
|
7
|
+
# Useful for testing or when persistence is not needed.
|
|
8
|
+
class Memory < Base
|
|
9
|
+
def initialize
|
|
10
|
+
super
|
|
11
|
+
@data = nil
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Save definitions to memory
|
|
16
|
+
#
|
|
17
|
+
# @param definitions [Hash<String, FeatureDefinition>] Definitions
|
|
18
|
+
# @param metadata [Hash] Optional metadata
|
|
19
|
+
def save(definitions, metadata = {})
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@data = {
|
|
22
|
+
definitions: serialize_definitions(definitions),
|
|
23
|
+
metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Load definitions from memory
|
|
29
|
+
#
|
|
30
|
+
# @return [Hash, nil] Hash with :definitions and :metadata
|
|
31
|
+
def load
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
return nil unless @data
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
definitions: deserialize_definitions(@data[:definitions]),
|
|
37
|
+
metadata: @data[:metadata]
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Clear the snapshot
|
|
43
|
+
def clear
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@data = nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if snapshot exists
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def exists?
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
!@data.nil?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|