Skip to content

Commit 49dafd3

Browse files
committed
Implement UFC configuration parsing with comprehensive Ruby data structures
Create complete Ruby data structures for Universal Flag Configuration (UFC) parsing, enabling InternalEvaluator to process the same JSON format used by Rust evaluation libraries. Includes comprehensive test coverage using UFC test data. Features: - Complete Ruby classes for Flag, Allocation, Split, Rule, Condition structures - Support for all variation types: STRING, INTEGER, NUMERIC, BOOLEAN, JSON - Full condition operators: MATCHES, GTE, ONE_OF, IS_NULL, etc. - Timestamp parsing for both Unix timestamps and ISO8601 strings - Graceful error handling with sensible defaults - Support for both 'flags' and 'flagsV1' JSON structure variants Test Coverage: - Copy all UFC test data from Rust implementation (flags-v1.json + 19 test cases) - Comprehensive RSpec tests validating parsing scenarios - Verified compatibility with 17 real flags including complex allocations - Tests edge cases, error conditions, and all variation types
1 parent 4ead7bd commit 49dafd3

24 files changed

+7048
-6
lines changed

lib/datadog/open_feature/binding.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ module Binding
1010

1111
require_relative 'binding/evaluator'
1212
require_relative 'binding/internal_evaluator'
13+
require_relative 'binding/configuration'
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
# frozen_string_literal: true
2+
3+
module Datadog
4+
module OpenFeature
5+
module Binding
6+
# Variation types supported by UFC
7+
module VariationType
8+
STRING = 'STRING'
9+
INTEGER = 'INTEGER'
10+
NUMERIC = 'NUMERIC'
11+
BOOLEAN = 'BOOLEAN'
12+
JSON = 'JSON'
13+
end
14+
15+
# Condition operators for rule evaluation
16+
module ConditionOperator
17+
MATCHES = 'MATCHES'
18+
NOT_MATCHES = 'NOT_MATCHES'
19+
GTE = 'GTE'
20+
GT = 'GT'
21+
LTE = 'LTE'
22+
LT = 'LT'
23+
ONE_OF = 'ONE_OF'
24+
NOT_ONE_OF = 'NOT_ONE_OF'
25+
IS_NULL = 'IS_NULL'
26+
end
27+
28+
# Assignment reasons returned in ResolutionDetails
29+
module AssignmentReason
30+
TARGETING_MATCH = 'TARGETING_MATCH'
31+
SPLIT = 'SPLIT'
32+
STATIC = 'STATIC'
33+
end
34+
35+
# Represents a feature flag configuration
36+
class Flag
37+
attr_reader :key, :enabled, :variation_type, :variations, :allocations
38+
39+
def initialize(key:, enabled:, variation_type:, variations:, allocations:)
40+
@key = key
41+
@enabled = enabled
42+
@variation_type = variation_type
43+
@variations = variations || {}
44+
@allocations = allocations || []
45+
end
46+
47+
def self.from_json(key, flag_data)
48+
new(
49+
key: key,
50+
enabled: flag_data['enabled'] || false,
51+
variation_type: flag_data['variationType'],
52+
variations: parse_variations(flag_data['variations'] || {}),
53+
allocations: parse_allocations(flag_data['allocations'] || [])
54+
)
55+
end
56+
57+
private
58+
59+
def self.parse_variations(variations_data)
60+
variations_data.transform_values do |variation_data|
61+
Variation.from_json(variation_data)
62+
end
63+
end
64+
65+
def self.parse_allocations(allocations_data)
66+
allocations_data.map { |allocation_data| Allocation.from_json(allocation_data) }
67+
end
68+
end
69+
70+
# Represents a variation value for a flag
71+
class Variation
72+
attr_reader :key, :value
73+
74+
def initialize(key:, value:)
75+
@key = key
76+
@value = value
77+
end
78+
79+
def self.from_json(variation_data)
80+
new(
81+
key: variation_data['key'],
82+
value: variation_data['value']
83+
)
84+
end
85+
end
86+
87+
# Represents an allocation rule with traffic splits
88+
class Allocation
89+
attr_reader :key, :rules, :start_at, :end_at, :splits, :do_log
90+
91+
def initialize(key:, rules: nil, start_at: nil, end_at: nil, splits:, do_log: true)
92+
@key = key
93+
@rules = rules
94+
@start_at = start_at
95+
@end_at = end_at
96+
@splits = splits || []
97+
@do_log = do_log
98+
end
99+
100+
def self.from_json(allocation_data)
101+
new(
102+
key: allocation_data['key'],
103+
rules: parse_rules(allocation_data['rules']),
104+
start_at: parse_timestamp(allocation_data['startAt']),
105+
end_at: parse_timestamp(allocation_data['endAt']),
106+
splits: parse_splits(allocation_data['splits'] || []),
107+
do_log: allocation_data.fetch('doLog', true)
108+
)
109+
end
110+
111+
private
112+
113+
def self.parse_rules(rules_data)
114+
return nil if rules_data.nil? || rules_data.empty?
115+
116+
rules_data.map { |rule_data| Rule.from_json(rule_data) }
117+
end
118+
119+
def self.parse_splits(splits_data)
120+
splits_data.map { |split_data| Split.from_json(split_data) }
121+
end
122+
123+
def self.parse_timestamp(timestamp_data)
124+
return nil if timestamp_data.nil?
125+
126+
# Handle both Unix timestamps and ISO8601 strings
127+
case timestamp_data
128+
when Numeric
129+
Time.at(timestamp_data)
130+
when String
131+
Time.parse(timestamp_data)
132+
else
133+
nil
134+
end
135+
rescue StandardError
136+
nil
137+
end
138+
end
139+
140+
# Represents a traffic split within an allocation
141+
class Split
142+
attr_reader :shards, :variation_key, :extra_logging
143+
144+
def initialize(shards:, variation_key:, extra_logging: nil)
145+
@shards = shards || []
146+
@variation_key = variation_key
147+
@extra_logging = extra_logging || {}
148+
end
149+
150+
def self.from_json(split_data)
151+
new(
152+
shards: parse_shards(split_data['shards'] || []),
153+
variation_key: split_data['variationKey'],
154+
extra_logging: split_data['extraLogging'] || {}
155+
)
156+
end
157+
158+
private
159+
160+
def self.parse_shards(shards_data)
161+
shards_data.map { |shard_data| Shard.from_json(shard_data) }
162+
end
163+
end
164+
165+
# Represents a shard configuration for traffic splitting
166+
class Shard
167+
attr_reader :salt, :total_shards, :ranges
168+
169+
def initialize(salt:, total_shards:, ranges:)
170+
@salt = salt
171+
@total_shards = total_shards
172+
@ranges = ranges || []
173+
end
174+
175+
def self.from_json(shard_data)
176+
new(
177+
salt: shard_data['salt'],
178+
total_shards: shard_data['totalShards'],
179+
ranges: parse_ranges(shard_data['ranges'] || [])
180+
)
181+
end
182+
183+
private
184+
185+
def self.parse_ranges(ranges_data)
186+
ranges_data.map { |range_data| ShardRange.from_json(range_data) }
187+
end
188+
end
189+
190+
# Represents a shard range for traffic allocation
191+
class ShardRange
192+
attr_reader :start, :end_value
193+
194+
def initialize(start:, end_value:)
195+
@start = start
196+
@end_value = end_value
197+
end
198+
199+
def self.from_json(range_data)
200+
new(
201+
start: range_data['start'],
202+
end_value: range_data['end']
203+
)
204+
end
205+
206+
# Alias for backward compatibility
207+
def end
208+
@end_value
209+
end
210+
end
211+
212+
# Represents a targeting rule
213+
class Rule
214+
attr_reader :conditions
215+
216+
def initialize(conditions:)
217+
@conditions = conditions || []
218+
end
219+
220+
def self.from_json(rule_data)
221+
new(
222+
conditions: parse_conditions(rule_data['conditions'] || [])
223+
)
224+
end
225+
226+
private
227+
228+
def self.parse_conditions(conditions_data)
229+
conditions_data.map { |condition_data| Condition.from_json(condition_data) }
230+
end
231+
end
232+
233+
# Represents a single condition within a rule
234+
class Condition
235+
attr_reader :attribute, :operator, :value
236+
237+
def initialize(attribute:, operator:, value:)
238+
@attribute = attribute
239+
@operator = operator
240+
@value = value
241+
end
242+
243+
def self.from_json(condition_data)
244+
new(
245+
attribute: condition_data['attribute'],
246+
operator: condition_data['operator'],
247+
value: parse_condition_value(condition_data['value'])
248+
)
249+
end
250+
251+
private
252+
253+
def self.parse_condition_value(value_data)
254+
# Handle both single values and arrays for ONE_OF/NOT_ONE_OF operators
255+
case value_data
256+
when Array
257+
value_data
258+
else
259+
value_data
260+
end
261+
end
262+
end
263+
264+
# Main configuration container
265+
class Configuration
266+
attr_reader :flags, :schema_version
267+
268+
def initialize(flags:, schema_version: nil)
269+
@flags = flags || {}
270+
@schema_version = schema_version
271+
end
272+
273+
def self.from_json(config_data)
274+
flags_data = config_data['flags'] || config_data['flagsV1'] || {}
275+
276+
parsed_flags = flags_data.transform_values do |flag_data|
277+
Flag.from_json(flag_data['key'] || '', flag_data)
278+
end
279+
280+
new(
281+
flags: parsed_flags,
282+
schema_version: config_data['schemaVersion']
283+
)
284+
end
285+
286+
def get_flag(flag_key)
287+
@flags[flag_key]
288+
end
289+
end
290+
end
291+
end
292+
end

lib/datadog/open_feature/binding/internal_evaluator.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def parse_and_validate_json(ufc_json)
3535
# Handle nil or empty input
3636
if ufc_json.nil? || ufc_json.strip.empty?
3737
# TODO: Add structured logging for debugging context
38-
return create_error_result('CONFIGURATION_MISSING', 'flags configuration is missing')
38+
return create_parse_error('CONFIGURATION_MISSING', 'flags configuration is missing')
3939
end
4040

4141
# Parse JSON
@@ -44,26 +44,26 @@ def parse_and_validate_json(ufc_json)
4444
# Basic structure validation
4545
unless parsed_json.is_a?(Hash)
4646
# TODO: Add structured logging for debugging context
47-
return create_error_result('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
47+
return create_parse_error('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
4848
end
4949

5050
# Check for required top-level fields (basic validation for now)
5151
unless parsed_json.key?('flags') || parsed_json.key?('flagsV1')
5252
# TODO: Add structured logging for debugging context
53-
return create_error_result('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
53+
return create_parse_error('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
5454
end
5555

5656
# Return parsed configuration if valid
5757
parsed_json
5858
rescue JSON::ParserError => e
5959
# TODO: Add structured logging: "Invalid JSON syntax: #{e.message}"
60-
create_error_result('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
60+
create_parse_error('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
6161
rescue StandardError => e
6262
# TODO: Add structured logging: "Unexpected error: #{e.message}"
63-
create_error_result('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
63+
create_parse_error('CONFIGURATION_PARSE_ERROR', 'failed to parse configuration')
6464
end
6565

66-
def create_error_result(error_code, error_message)
66+
def create_parse_error(error_code, error_message)
6767
ResolutionDetails.new(
6868
value: nil,
6969
error_code: error_code,

0 commit comments

Comments
 (0)