featurevisor 0.1.1

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.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Featurevisor
6
+ # Conditions module for evaluating feature flags and segments
7
+ module Conditions
8
+ # Get value from context object using dot notation path
9
+ # @param obj [Hash] Context object
10
+ # @param path [String] Dot-separated path to the value
11
+ # @return [Object, nil] Value at the path or nil if not found
12
+ def self.get_value_from_context(obj, path)
13
+ return nil if obj.nil? || path.nil?
14
+
15
+ if path.index(".") == -1
16
+ return obj[path.to_sym] || obj[path]
17
+ end
18
+
19
+ path.split(".").reduce(obj) { |o, i| o&.[](i.to_sym) || o&.[](i) }
20
+ end
21
+
22
+ # Check if a condition is matched against context
23
+ # @param condition [Hash] Condition to evaluate
24
+ # @param context [Hash] Context to evaluate against
25
+ # @param get_regex [Proc] Function to get regex for pattern matching
26
+ # @return [Boolean] True if condition matches
27
+ def self.condition_is_matched(condition, context, get_regex)
28
+ attribute = condition["attribute"] || condition[:attribute]
29
+ operator = condition["operator"] || condition[:operator]
30
+ value = condition["value"] || condition[:value]
31
+ regex_flags = condition["regexFlags"] || condition[:regexFlags]
32
+
33
+ context_value_from_path = get_value_from_context(context, attribute)
34
+
35
+ case operator
36
+ when "equals"
37
+ context_value_from_path == value
38
+ when "notEquals"
39
+ context_value_from_path != value
40
+ when "before", "after"
41
+ # date comparisons
42
+ value_in_context = context_value_from_path
43
+ date_in_context = value_in_context.is_a?(Date) ? value_in_context : Date.parse(value_in_context.to_s)
44
+ date_in_condition = value.is_a?(Date) ? value : Date.parse(value.to_s)
45
+
46
+ if operator == "before"
47
+ date_in_context < date_in_condition
48
+ else
49
+ date_in_context > date_in_condition
50
+ end
51
+ when "in", "notIn"
52
+ # in / notIn (where condition value is an array)
53
+ if value.is_a?(Array) && (context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
54
+ # Check if the attribute key actually exists in the context
55
+ key_exists = context.key?(attribute.to_sym) || context.key?(attribute.to_s)
56
+
57
+ # If key doesn't exist, notIn should fail (return false), in should also fail
58
+ if !key_exists
59
+ return false
60
+ end
61
+
62
+ value_in_context = context_value_from_path.to_s
63
+
64
+ if operator == "in"
65
+ value.include?(value_in_context)
66
+ else # notIn
67
+ !value.include?(value_in_context)
68
+ end
69
+ else
70
+ false
71
+ end
72
+ when "contains", "notContains", "startsWith", "endsWith", "semverEquals", "semverNotEquals", "semverGreaterThan", "semverGreaterThanOrEquals", "semverLessThan", "semverLessThanOrEquals", "matches", "notMatches"
73
+ # string operations
74
+ if context_value_from_path.is_a?(String) && value.is_a?(String)
75
+ value_in_context = context_value_from_path
76
+
77
+ case operator
78
+ when "contains"
79
+ value_in_context.include?(value)
80
+ when "notContains"
81
+ !value_in_context.include?(value)
82
+ when "startsWith"
83
+ value_in_context.start_with?(value)
84
+ when "endsWith"
85
+ value_in_context.end_with?(value)
86
+ when "semverEquals"
87
+ Featurevisor.compare_versions(value_in_context, value) == 0
88
+ when "semverNotEquals"
89
+ Featurevisor.compare_versions(value_in_context, value) != 0
90
+ when "semverGreaterThan"
91
+ Featurevisor.compare_versions(value_in_context, value) == 1
92
+ when "semverGreaterThanOrEquals"
93
+ Featurevisor.compare_versions(value_in_context, value) >= 0
94
+ when "semverLessThan"
95
+ Featurevisor.compare_versions(value_in_context, value) == -1
96
+ when "semverLessThanOrEquals"
97
+ Featurevisor.compare_versions(value_in_context, value) <= 0
98
+ when "matches"
99
+ regex = get_regex.call(value, regex_flags || "")
100
+ regex.match?(value_in_context)
101
+ when "notMatches"
102
+ regex = get_regex.call(value, regex_flags || "")
103
+ !regex.match?(value_in_context)
104
+ end
105
+ else
106
+ false
107
+ end
108
+ when "greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals"
109
+ # numeric operations
110
+ if context_value_from_path.is_a?(Numeric) && value.is_a?(Numeric)
111
+ value_in_context = context_value_from_path
112
+
113
+ case operator
114
+ when "greaterThan"
115
+ value_in_context > value
116
+ when "greaterThanOrEquals"
117
+ value_in_context >= value
118
+ when "lessThan"
119
+ value_in_context < value
120
+ when "lessThanOrEquals"
121
+ value_in_context <= value
122
+ end
123
+ else
124
+ false
125
+ end
126
+ when "exists"
127
+ context_value_from_path != nil
128
+ when "notExists"
129
+ context_value_from_path.nil?
130
+ when "includes", "notIncludes"
131
+ # includes / notIncludes (where context value is an array)
132
+ if context_value_from_path.is_a?(Array) && value.is_a?(String)
133
+ value_in_context = context_value_from_path
134
+
135
+ if operator == "includes"
136
+ value_in_context.include?(value)
137
+ else # notIncludes
138
+ !value_in_context.include?(value)
139
+ end
140
+ else
141
+ false
142
+ end
143
+ else
144
+ false
145
+ end
146
+ rescue => e
147
+ # Log error but don't stop execution
148
+ warn "Error in condition evaluation: #{e.message}"
149
+ false
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Featurevisor
6
+ # DatafileReader class for reading and processing Featurevisor datafiles
7
+ class DatafileReader
8
+ attr_reader :schema_version, :revision, :segments, :features, :logger, :regex_cache
9
+
10
+ # Initialize a new DatafileReader
11
+ # @param options [Hash] Options hash containing datafile and logger
12
+ # @option options [Hash] :datafile Datafile content
13
+ # @option options [Logger] :logger Logger instance
14
+ def initialize(options)
15
+ datafile = options[:datafile]
16
+ @logger = options[:logger]
17
+
18
+ @schema_version = datafile[:schemaVersion]
19
+ @revision = datafile[:revision]
20
+ @segments = (datafile[:segments] || {}).transform_keys(&:to_sym)
21
+ @features = (datafile[:features] || {}).transform_keys(&:to_sym)
22
+
23
+ # Transform nested structures to use symbol keys
24
+ @features.each do |_key, feature|
25
+ if feature[:variablesSchema]
26
+ feature[:variablesSchema] = feature[:variablesSchema].transform_keys(&:to_sym)
27
+ end
28
+ if feature[:variations]
29
+ feature[:variations].each do |variation|
30
+ if variation[:variables]
31
+ variation[:variables] = variation[:variables].transform_keys(&:to_sym)
32
+ end
33
+ if variation[:variableOverrides]
34
+ variation[:variableOverrides] = variation[:variableOverrides].transform_keys(&:to_sym)
35
+ end
36
+ end
37
+ end
38
+ if feature[:force]
39
+ feature[:force].each do |force_rule|
40
+ if force_rule[:variables]
41
+ force_rule[:variables] = force_rule[:variables].transform_keys(&:to_sym)
42
+ end
43
+ end
44
+ end
45
+ if feature[:traffic]
46
+ feature[:traffic].each do |traffic_rule|
47
+ if traffic_rule[:variables]
48
+ traffic_rule[:variables] = traffic_rule[:variables].transform_keys(&:to_sym)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ @regex_cache = {}
54
+ end
55
+
56
+ # Get the revision of the datafile
57
+ # @return [String] Revision string
58
+ def get_revision
59
+ @revision
60
+ end
61
+
62
+ # Get the schema version of the datafile
63
+ # @return [String] Schema version string
64
+ def get_schema_version
65
+ @schema_version
66
+ end
67
+
68
+ # Get a segment by key
69
+ # @param segment_key [String] Segment key to retrieve
70
+ # @return [Hash, nil] Segment data or nil if not found
71
+ def get_segment(segment_key)
72
+ segment = @segments[segment_key.to_sym] || @segments[segment_key]
73
+
74
+ return nil unless segment
75
+
76
+ segment[:conditions] = parse_conditions_if_stringified(segment[:conditions])
77
+ segment
78
+ end
79
+
80
+ # Get all feature keys
81
+ # @return [Array<Symbol>] Array of feature keys
82
+ def get_feature_keys
83
+ @features.keys
84
+ end
85
+
86
+ # Get a feature by key
87
+ # @param feature_key [String] Feature key to retrieve
88
+ # @return [Hash, nil] Feature data or nil if not found
89
+ def get_feature(feature_key)
90
+ @features[feature_key.to_sym] || @features[feature_key]
91
+ end
92
+
93
+ # Get variable keys for a feature
94
+ # @param feature_key [String] Feature key
95
+ # @return [Array<String>] Array of variable keys
96
+ def get_variable_keys(feature_key)
97
+ feature = get_feature(feature_key)
98
+
99
+ return [] unless feature
100
+
101
+ (feature[:variablesSchema] || {}).keys
102
+ end
103
+
104
+ # Check if a feature has variations
105
+ # @param feature_key [String] Feature key
106
+ # @return [Boolean] True if feature has variations
107
+ def has_variations?(feature_key)
108
+ feature = get_feature(feature_key)
109
+
110
+ return false unless feature
111
+
112
+ feature[:variations].is_a?(Array) && feature[:variations].size > 0
113
+ end
114
+
115
+ # Get a regex object with caching
116
+ # @param regex_string [String] Regex pattern string
117
+ # @param regex_flags [String] Regex flags (optional)
118
+ # @return [Regexp] Compiled regex object
119
+ def get_regex(regex_string, regex_flags = "")
120
+ flags = regex_flags || ""
121
+ cache_key = "#{regex_string}-#{flags}"
122
+
123
+ return @regex_cache[cache_key] if @regex_cache[cache_key]
124
+
125
+ regex = Regexp.new(regex_string, flags)
126
+ @regex_cache[cache_key] = regex
127
+ @regex_cache[cache_key]
128
+ end
129
+
130
+ # Check if all conditions are matched against context
131
+ # @param conditions [Array<Hash>, Hash, String] Conditions to evaluate
132
+ # @param context [Hash] Context to evaluate against
133
+ # @return [Boolean] True if all conditions match
134
+ def all_conditions_are_matched(conditions, context)
135
+ if conditions.is_a?(String)
136
+ return true if conditions == "*"
137
+ return false
138
+ end
139
+
140
+ get_regex_proc = ->(regex_string, regex_flags) { get_regex(regex_string, regex_flags) }
141
+
142
+ if conditions.is_a?(Hash) && (conditions[:attribute] || conditions["attribute"])
143
+ begin
144
+ result = Conditions.condition_is_matched(conditions, context, get_regex_proc)
145
+ return result
146
+ rescue => e
147
+ @logger.warn("Error in condition evaluation: #{e.message}", {
148
+ error: e.class.name,
149
+ details: {
150
+ condition: conditions,
151
+ context: context
152
+ }
153
+ })
154
+ return false
155
+ end
156
+ end
157
+
158
+ if conditions.is_a?(Hash) && conditions[:and] && conditions[:and].is_a?(Array)
159
+ return conditions[:and].all? { |c| all_conditions_are_matched(c, context) }
160
+ end
161
+
162
+ if conditions.is_a?(Hash) && conditions["and"] && conditions["and"].is_a?(Array)
163
+ return conditions["and"].all? { |c| all_conditions_are_matched(c, context) }
164
+ end
165
+
166
+ if conditions.is_a?(Hash) && conditions[:or] && conditions[:or].is_a?(Array)
167
+ return conditions[:or].any? { |c| all_conditions_are_matched(c, context) }
168
+ end
169
+
170
+ if conditions.is_a?(Hash) && conditions["or"] && conditions["or"].is_a?(Array)
171
+ return conditions["or"].any? { |c| all_conditions_are_matched(c, context) }
172
+ end
173
+
174
+ if conditions.is_a?(Hash) && conditions[:not] && conditions[:not].is_a?(Array)
175
+ return conditions[:not].all? do
176
+ all_conditions_are_matched({ and: conditions[:not] }, context) == false
177
+ end
178
+ end
179
+
180
+ if conditions.is_a?(Hash) && conditions["not"] && conditions["not"].is_a?(Array)
181
+ return conditions["not"].all? do
182
+ all_conditions_are_matched({ "and" => conditions["not"] }, context) == false
183
+ end
184
+ end
185
+
186
+ if conditions.is_a?(Array)
187
+ return conditions.all? { |c| all_conditions_are_matched(c, context) }
188
+ end
189
+
190
+ false
191
+ end
192
+
193
+ # Check if a segment is matched against context
194
+ # @param segment [Hash] Segment to evaluate
195
+ # @param context [Hash] Context to evaluate against
196
+ # @return [Boolean] True if segment matches
197
+ def segment_is_matched(segment, context)
198
+ all_conditions_are_matched(segment[:conditions], context)
199
+ end
200
+
201
+ # Check if all segments are matched against context
202
+ # @param group_segments [String, Array, Hash] Segments to evaluate
203
+ # @param context [Hash] Context to evaluate against
204
+ # @return [Boolean] True if all segments match
205
+ def all_segments_are_matched(group_segments, context)
206
+ if group_segments == "*"
207
+ return true
208
+ end
209
+
210
+ if group_segments.is_a?(String)
211
+ segment = get_segment(group_segments)
212
+
213
+ if segment
214
+ return segment_is_matched(segment, context)
215
+ end
216
+
217
+ return false
218
+ end
219
+
220
+ if group_segments.is_a?(Hash)
221
+ if group_segments[:and] && group_segments[:and].is_a?(Array)
222
+ return group_segments[:and].all? { |group_segment| all_segments_are_matched(group_segment, context) }
223
+ end
224
+
225
+ if group_segments["and"] && group_segments["and"].is_a?(Array)
226
+ return group_segments["and"].all? { |group_segment| all_segments_are_matched(group_segment, context) }
227
+ end
228
+
229
+ if group_segments[:or] && group_segments[:or].is_a?(Array)
230
+ return group_segments[:or].any? { |group_segment| all_segments_are_matched(group_segment, context) }
231
+ end
232
+
233
+ if group_segments["or"] && group_segments["or"].is_a?(Array)
234
+ return group_segments["or"].any? { |group_segment| all_segments_are_matched(group_segment, context) }
235
+ end
236
+
237
+ if group_segments[:not] && group_segments[:not].is_a?(Array)
238
+ return group_segments[:not].all? do
239
+ all_segments_are_matched({ and: group_segments[:not] }, context) == false
240
+ end
241
+ end
242
+
243
+ if group_segments["not"] && group_segments["not"].is_a?(Array)
244
+ return group_segments["not"].all? do
245
+ all_segments_are_matched({ "and" => group_segments["not"] }, context) == false
246
+ end
247
+ end
248
+ end
249
+
250
+ if group_segments.is_a?(Array)
251
+ return group_segments.all? { |group_segment| all_segments_are_matched(group_segment, context) }
252
+ end
253
+
254
+ false
255
+ end
256
+
257
+ # Get matched traffic based on context
258
+ # @param traffic [Array<Hash>] Traffic array to search
259
+ # @param context [Hash] Context to evaluate against
260
+ # @return [Hash, nil] Matched traffic or nil
261
+ def get_matched_traffic(traffic, context)
262
+ traffic.find do |t|
263
+ segments = parse_segments_if_stringified(t[:segments])
264
+ matched = all_segments_are_matched(segments, context)
265
+ next false unless matched
266
+ true
267
+ end
268
+ end
269
+
270
+ # Get matched allocation based on bucket value
271
+ # @param traffic [Hash] Traffic object
272
+ # @param bucket_value [Numeric] Bucket value to match
273
+ # @return [Hash, nil] Matched allocation or nil
274
+ def get_matched_allocation(traffic, bucket_value)
275
+ return nil unless traffic[:allocation]
276
+
277
+ traffic[:allocation].find do |allocation|
278
+ start, end_val = allocation[:range]
279
+ start <= bucket_value && end_val >= bucket_value
280
+ end
281
+ end
282
+
283
+ # Get matched force based on context
284
+ # @param feature_key [String, Hash] Feature key or feature object
285
+ # @param context [Hash] Context to evaluate against
286
+ # @return [Hash] Force result with force and forceIndex
287
+ def get_matched_force(feature_key, context)
288
+ result = {
289
+ force: nil,
290
+ forceIndex: nil
291
+ }
292
+
293
+ feature = feature_key.is_a?(String) ? get_feature(feature_key) : feature_key
294
+
295
+ return result unless feature && feature[:force]
296
+
297
+ feature[:force].each_with_index do |current_force, i|
298
+ if current_force[:conditions] && all_conditions_are_matched(
299
+ parse_conditions_if_stringified(current_force[:conditions]), context
300
+ )
301
+ result[:force] = current_force
302
+ result[:forceIndex] = i
303
+ break
304
+ end
305
+
306
+ if current_force[:segments] && all_segments_are_matched(
307
+ parse_segments_if_stringified(current_force[:segments]), context
308
+ )
309
+ result[:force] = current_force
310
+ result[:forceIndex] = i
311
+ break
312
+ end
313
+ end
314
+
315
+ result
316
+ end
317
+
318
+ # Parse conditions if they are stringified
319
+ # @param conditions [String, Array, Hash] Conditions to parse
320
+ # @return [Array, Hash, String] Parsed conditions
321
+ def parse_conditions_if_stringified(conditions)
322
+ return conditions unless conditions.is_a?(String)
323
+
324
+ return conditions if conditions == "*"
325
+
326
+ begin
327
+ JSON.parse(conditions)
328
+ rescue => e
329
+ @logger.error("Error parsing conditions", {
330
+ error: e,
331
+ details: {
332
+ conditions: conditions
333
+ }
334
+ })
335
+ conditions
336
+ end
337
+ end
338
+
339
+ # Parse segments if they are stringified
340
+ # @param segments [String, Array, Hash] Segments to parse
341
+ # @return [Array, Hash, String] Parsed segments
342
+ def parse_segments_if_stringified(segments)
343
+ if segments.is_a?(String) && (segments.start_with?("{") || segments.start_with?("["))
344
+ return JSON.parse(segments)
345
+ end
346
+
347
+ segments
348
+ end
349
+ end
350
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Featurevisor
4
+ # Event names for the emitter
5
+ EVENT_NAMES = %w[datafile_set context_set sticky_set].freeze
6
+
7
+ # Event emitter class for handling event subscriptions and triggers
8
+ class Emitter
9
+ attr_reader :listeners
10
+
11
+ # Initialize a new emitter
12
+ def initialize
13
+ @listeners = {}
14
+ end
15
+
16
+ # Subscribe to an event
17
+ # @param event_name [String] Name of the event to listen to
18
+ # @param callback [Proc] Callback function to execute when event is triggered
19
+ # @return [Proc] Unsubscribe function
20
+ def on(event_name, callback)
21
+ @listeners[event_name] ||= []
22
+ listeners = @listeners[event_name]
23
+ listeners << callback
24
+
25
+ is_active = true
26
+
27
+ # Return unsubscribe function
28
+ -> do
29
+ return unless is_active
30
+
31
+ is_active = false
32
+ index = listeners.index(callback)
33
+ listeners.delete_at(index) if index && index >= 0
34
+ end
35
+ end
36
+
37
+ # Trigger an event with optional details
38
+ # @param event_name [String] Name of the event to trigger
39
+ # @param details [Hash] Optional details to pass to event handlers
40
+ def trigger(event_name, details = {})
41
+ listeners = @listeners[event_name]
42
+
43
+ return unless listeners
44
+
45
+ listeners.each do |listener|
46
+ begin
47
+ listener.call(details)
48
+ rescue => err
49
+ # Log error but don't stop execution
50
+ warn "Error in event listener: #{err.message}"
51
+ end
52
+ end
53
+ end
54
+
55
+ # Clear all event listeners
56
+ def clear_all
57
+ @listeners = {}
58
+ end
59
+ end
60
+ end