flagnetic 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.
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flagnetic
6
+ module Eval
7
+ # SCHEMA_VERSION is the ruleset schema version this evaluator reads.
8
+ SCHEMA_VERSION = 1
9
+
10
+ # SCOPE is the only scope of a ruleset this evaluator reads: the
11
+ # document an SDK evaluates locally holds everything the environment
12
+ # decides, private segments included. Another scope is a document made
13
+ # for another reader.
14
+ SCOPE = "server"
15
+
16
+ # DecodeError is a document that is not a ruleset this evaluator can
17
+ # read.
18
+ class DecodeError < StandardError; end
19
+
20
+ # Ruleset is the complete evaluable configuration of one environment at
21
+ # one moment. Build it with Ruleset.decode. It is frozen and safe to
22
+ # share between threads.
23
+ class Ruleset
24
+ attr_reader :schema_version, :version, :scope, :environment, :unit_types, :attributes, :segments, :flags
25
+
26
+ # decode reads a ruleset document from JSON text. It raises
27
+ # DecodeError only when the document is not a ruleset this evaluator
28
+ # can read: malformed JSON, a field of the wrong JSON type, an
29
+ # unsupported schema_version or a scope other than server. Unknown
30
+ # fields are ignored and null reads as absent. Dangling references,
31
+ # invalid conditions, invalid experiment serves and constructs this
32
+ # evaluator does not know do not fail decoding; evaluating a flag that
33
+ # reaches one returns reason error with inconsistent_ruleset, so one
34
+ # defective flag never takes the others down.
35
+ def self.decode(json)
36
+ raise DecodeError, "decode ruleset: want JSON text, got #{json.class}" unless json.is_a?(String)
37
+
38
+ doc = JSON.parse(Values.utf8(json), freeze: true, max_nesting: 10_000)
39
+ new(**Document.ruleset(doc))
40
+ rescue JSON::ParserError, EncodingError => e
41
+ raise DecodeError, "decode ruleset: #{e.message}"
42
+ end
43
+
44
+ private_class_method :new
45
+
46
+ def initialize(schema_version:, version:, scope:, environment:, unit_types:, attributes:, segments:, flags:)
47
+ if schema_version != SCHEMA_VERSION
48
+ raise DecodeError, "decode ruleset: unsupported schema_version #{schema_version}, " \
49
+ "this evaluator reads #{SCHEMA_VERSION}"
50
+ end
51
+ unless scope == SCOPE
52
+ raise DecodeError, "decode ruleset: scope #{scope.inspect}, this evaluator reads #{SCOPE.inspect} only"
53
+ end
54
+
55
+ @schema_version = schema_version
56
+ @version = version
57
+ @scope = scope
58
+ @environment = environment
59
+ @unit_types = unit_types
60
+ @attributes = attributes
61
+ @segments = segments
62
+ @flags = flags
63
+ @index = Index.new(self)
64
+ freeze
65
+ end
66
+
67
+ # protected? reports whether the ruleset belongs to a protected
68
+ # environment. SDKs refuse local overrides there. A document without
69
+ # an environment (a hand-written offline file) is unprotected.
70
+ def protected?
71
+ environment&.protected == true
72
+ end
73
+
74
+ # flag returns the flag with key as the document has it, or nil. SDKs
75
+ # check a flag's type against the one requested.
76
+ def flag(key)
77
+ @index.flags[key]&.flag
78
+ end
79
+
80
+ # unsupported_rules reports, by flag key, the constructs each flag
81
+ # holds that this evaluator does not know: an operator, a condition
82
+ # kind or a serve kind, typically sent by a server newer than the SDK.
83
+ # The constructs come in document order, each once; a flag holds what
84
+ # its rules hold and what the segments its rules name hold. A flag
85
+ # holding none is absent, so the report is empty for a ruleset this
86
+ # evaluator fully supports.
87
+ def unsupported_rules
88
+ @index.unsupported
89
+ end
90
+
91
+ # evaluate computes one flag for a context. It never reads a clock,
92
+ # never records an exposure and never fails: problems are results with
93
+ # reason error.
94
+ def evaluate(flag_key, context = nil)
95
+ Evaluation.new(@index, Context.__send__(:coerce, context)).flag(flag_key)
96
+ end
97
+
98
+ # evaluate_all evaluates every flag of the ruleset for a context, keyed
99
+ # by flag key. Prerequisite results are memoized within the call.
100
+ def evaluate_all(context = nil)
101
+ e = Evaluation.new(@index, Context.__send__(:coerce, context))
102
+ flags.to_h { |f| [f.key, e.flag(f.key)] }.freeze
103
+ end
104
+ end
105
+
106
+ # Document reads the decoded JSON into frozen structs, refusing a field
107
+ # of the wrong JSON type the way the reference evaluator's typed
108
+ # decoding does. A missing or null field is its zero value.
109
+ module Document
110
+ INT64 = (-(2**63))..((2**63) - 1)
111
+ private_constant :INT64
112
+
113
+ module_function
114
+
115
+ def ruleset(doc)
116
+ d = object(doc, "ruleset")
117
+ {
118
+ schema_version: int(d["schema_version"], "schema_version"),
119
+ version: int(d["version"], "version"),
120
+ scope: string(d["scope"], "scope"),
121
+ environment: d["environment"].nil? ? nil : environment(d["environment"]),
122
+ unit_types: list(d["unit_types"], "unit_types") { |u, p| string(u, p) },
123
+ attributes: list(d["attributes"], "attributes") { |a, p| attribute(a, p) },
124
+ segments: list(d["segments"], "segments") { |s, p| segment(s, p) },
125
+ flags: list(d["flags"], "flags") { |f, p| flag(f, p) }
126
+ }
127
+ end
128
+
129
+ def environment(value)
130
+ e = object(value, "environment")
131
+ Environment.new(key: string(e["key"], "environment.key"),
132
+ protected: bool(e["protected"], "environment.protected")).freeze
133
+ end
134
+
135
+ def attribute(value, path)
136
+ a = object(value, path)
137
+ Attribute.new(key: string(a["key"], "#{path}.key"), type: string(a["type"], "#{path}.type")).freeze
138
+ end
139
+
140
+ def segment(value, path)
141
+ s = object(value, path)
142
+ Segment.new(key: string(s["key"], "#{path}.key"), private: bool(s["private"], "#{path}.private"),
143
+ conditions: list(s["conditions"], "#{path}.conditions") { |c, p| condition(c, p) }).freeze
144
+ end
145
+
146
+ def condition(value, path)
147
+ c = object(value, path)
148
+ fields = %w[kind attribute op segment flag variation].to_h do |k|
149
+ [k.to_sym, string(c[k], "#{path}.#{k}")]
150
+ end
151
+ Condition.new(value: condition_value(c["value"], "#{path}.value"), **fields).freeze
152
+ end
153
+
154
+ # condition_value refuses a number beyond the range of a double, which
155
+ # the reference evaluator cannot decode into a condition value.
156
+ # Variation values stay raw JSON there and are not checked.
157
+ def condition_value(value, path)
158
+ case value
159
+ when Array then value.each_with_index { |e, i| condition_value(e, "#{path}[#{i}]") }
160
+ when Hash then value.each { |k, v| condition_value(v, "#{path}.#{k}") }
161
+ when Numeric then wrong(path, "a number within the range of a double", value) if value.to_f.infinite?
162
+ end
163
+ value
164
+ end
165
+
166
+ def flag(value, path)
167
+ f = object(value, path)
168
+ Flag.new(
169
+ key: string(f["key"], "#{path}.key"),
170
+ type: string(f["type"], "#{path}.type"),
171
+ automatic_exposure: bool(f["automatic_exposure"], "#{path}.automatic_exposure"),
172
+ variations: list(f["variations"], "#{path}.variations") { |v, p| variation(v, p) },
173
+ on: bool(f["on"], "#{path}.on"),
174
+ off_variation: string(f["off_variation"], "#{path}.off_variation"),
175
+ fallthrough: string(f["fallthrough"], "#{path}.fallthrough"),
176
+ prerequisites: list(f["prerequisites"], "#{path}.prerequisites") { |r, p| prerequisite(r, p) },
177
+ overrides: list(f["overrides"], "#{path}.overrides") { |o, p| override(o, p) },
178
+ rules: list(f["rules"], "#{path}.rules") { |r, p| rule(r, p) }
179
+ ).freeze
180
+ end
181
+
182
+ def variation(value, path)
183
+ v = object(value, path)
184
+ Variation.new(key: string(v["key"], "#{path}.key"), value: v["value"]).freeze
185
+ end
186
+
187
+ def prerequisite(value, path)
188
+ r = object(value, path)
189
+ Prerequisite.new(flag: string(r["flag"], "#{path}.flag"),
190
+ variation: string(r["variation"], "#{path}.variation")).freeze
191
+ end
192
+
193
+ def override(value, path)
194
+ o = object(value, path)
195
+ Override.new(unit_type: string(o["unit_type"], "#{path}.unit_type"),
196
+ unit_id: string(o["unit_id"], "#{path}.unit_id"),
197
+ variation: string(o["variation"], "#{path}.variation")).freeze
198
+ end
199
+
200
+ def rule(value, path)
201
+ r = object(value, path)
202
+ Rule.new(id: string(r["id"], "#{path}.id"),
203
+ conditions: list(r["conditions"], "#{path}.conditions") { |c, p| condition(c, p) },
204
+ serve: serve(r["serve"], "#{path}.serve")).freeze
205
+ end
206
+
207
+ def serve(value, path)
208
+ s = object(value, path)
209
+ strings = %w[kind variation unit_type salt experiment inclusion_salt variation_salt].to_h do |k|
210
+ [k.to_sym, string(s[k], "#{path}.#{k}")]
211
+ end
212
+ Serve.new(
213
+ percentage: int(s["percentage"], "#{path}.percentage"),
214
+ traffic_allocation: int(s["traffic_allocation"], "#{path}.traffic_allocation"),
215
+ weights: list(s["weights"], "#{path}.weights") { |w, p| weight(w, p) },
216
+ **strings
217
+ ).freeze
218
+ end
219
+
220
+ def weight(value, path)
221
+ w = object(value, path)
222
+ Weight.new(variation: string(w["variation"], "#{path}.variation"),
223
+ weight: int(w["weight"], "#{path}.weight")).freeze
224
+ end
225
+
226
+ def object(value, path)
227
+ return {} if value.nil?
228
+ return value if value.is_a?(Hash)
229
+
230
+ wrong(path, "an object", value)
231
+ end
232
+
233
+ def list(value, path)
234
+ return [].freeze if value.nil?
235
+
236
+ wrong(path, "a list", value) unless value.is_a?(Array)
237
+ value.each_with_index.map { |e, i| yield e, "#{path}[#{i}]" }.freeze
238
+ end
239
+
240
+ def string(value, path)
241
+ return "" if value.nil?
242
+ return value if value.is_a?(String)
243
+
244
+ wrong(path, "a string", value)
245
+ end
246
+
247
+ def bool(value, path)
248
+ return false if value.nil?
249
+ return value if [true, false].include?(value)
250
+
251
+ wrong(path, "a boolean", value)
252
+ end
253
+
254
+ def int(value, path)
255
+ return 0 if value.nil?
256
+ return value if value.is_a?(Integer) && INT64.cover?(value)
257
+
258
+ wrong(path, "an integer", value)
259
+ end
260
+
261
+ def wrong(path, want, value)
262
+ raise DecodeError, "decode ruleset: #{path} must be #{want}, got #{JSON.generate(value, allow_nan: true)}"
263
+ end
264
+ end
265
+ private_constant :Document
266
+ end
267
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ module Eval
5
+ # Semver is a parsed semantic version. Build metadata is dropped because
6
+ # it never affects precedence.
7
+ class Semver
8
+ include Comparable
9
+
10
+ CORE = /\A[0-9]+(?:\.[0-9]+){0,2}\z/
11
+ IDENTIFIERS = /\A[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*\z/
12
+ NUMERIC = /\A[0-9]+\z/
13
+ private_constant :CORE, :IDENTIFIERS, :NUMERIC
14
+
15
+ # parse reads an optional lower-case "v", one to three dot-separated
16
+ # non-negative integers (missing components are 0, so "1.41" equals
17
+ # "1.41.0"), an optional "-" pre-release and an optional "+" build.
18
+ # Numeric components are unbounded and leading zeros are ignored.
19
+ # Pre-release and build identifiers are non-empty runs of
20
+ # [0-9A-Za-z-]. It returns nil for anything else.
21
+ def self.parse(str)
22
+ return nil unless str.is_a?(String) && str.valid_encoding?
23
+
24
+ s = str.delete_prefix("v")
25
+ s, build = s.split("+", 2)
26
+ return nil if build && !build.match?(IDENTIFIERS)
27
+
28
+ s, prerelease = s.to_s.split("-", 2)
29
+ return nil if prerelease && !prerelease.match?(IDENTIFIERS)
30
+ return nil unless s.to_s.match?(CORE)
31
+
32
+ core = s.split(".").map(&:to_i)
33
+ new(core.fill(0, core.size...3), prerelease ? prerelease.split(".") : [])
34
+ end
35
+
36
+ attr_reader :core, :prerelease
37
+
38
+ def initialize(core, prerelease)
39
+ @core = core.freeze
40
+ @prerelease = prerelease.freeze
41
+ freeze
42
+ end
43
+
44
+ # <=> orders versions by SemVer 2.0.0 section 11 precedence.
45
+ def <=>(other)
46
+ return nil unless other.is_a?(Semver)
47
+
48
+ c = core <=> other.core
49
+ return c unless c.zero?
50
+ return 0 if prerelease.empty? && other.prerelease.empty?
51
+ return 1 if prerelease.empty?
52
+ return -1 if other.prerelease.empty?
53
+
54
+ prerelease.zip(other.prerelease).each do |a, b|
55
+ break if b.nil?
56
+
57
+ c = compare_identifiers(a, b)
58
+ return c unless c.zero?
59
+ end
60
+ prerelease.size <=> other.prerelease.size
61
+ end
62
+
63
+ private
64
+
65
+ # compare_identifiers orders pre-release identifiers: numeric ones
66
+ # numerically and below alphanumeric ones, alphanumeric ones in ASCII
67
+ # order.
68
+ def compare_identifiers(left, right)
69
+ ln = left.match?(NUMERIC)
70
+ rn = right.match?(NUMERIC)
71
+ return left.to_i <=> right.to_i if ln && rn
72
+ return -1 if ln
73
+ return 1 if rn
74
+
75
+ left.b <=> right.b
76
+ end
77
+ end
78
+ private_constant :Semver
79
+ end
80
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ module Eval
5
+ # Context is the subject of an evaluation: unit IDs by unit type, and
6
+ # attributes. A context with no units is valid: attribute rules apply;
7
+ # rollouts, experiments and overrides are skipped. An empty unit ID
8
+ # counts as absent. Keys may be strings or symbols.
9
+ #
10
+ # Attribute values: String, true or false, a real Numeric, an Array of
11
+ # strings, and for dates an RFC 3339 string, integral Unix epoch
12
+ # milliseconds or a Time.
13
+ Context = Struct.new(:units, :attributes, keyword_init: true) do
14
+ def initialize(units: nil, attributes: nil)
15
+ super(units: (units || {}).transform_keys(&:to_s).freeze,
16
+ attributes: (attributes || {}).transform_keys(&:to_s).freeze)
17
+ freeze
18
+ end
19
+
20
+ # unit returns the unit ID of a unit type, or nil when absent.
21
+ def unit(unit_type)
22
+ id = units[unit_type]
23
+ id.is_a?(String) && !id.empty? ? Values.utf8(id) : nil
24
+ end
25
+
26
+ # coerce accepts a Context, a Hash with units and attributes, or nil.
27
+ def self.coerce(context)
28
+ case context
29
+ when Context then context
30
+ when nil then self::EMPTY
31
+ when Hash
32
+ c = context.transform_keys(&:to_s)
33
+ new(units: c["units"], attributes: c["attributes"])
34
+ else raise ArgumentError, "context must be a Flagnetic::Eval::Context or a Hash, got #{context.class}"
35
+ end
36
+ end
37
+ private_class_method :coerce
38
+ end
39
+ Context::EMPTY = Context.new
40
+
41
+ # REASONS are the evaluation reasons the evaluator produces. fallback
42
+ # and local_override are produced by SDKs, never by the evaluator.
43
+ REASONS = %i[off override rule rollout experiment fallthrough prerequisite_failed error].freeze
44
+
45
+ # ERROR_CODES say why a result has reason error.
46
+ ERROR_CODES = %i[flag_not_found prerequisite_cycle inconsistent_ruleset].freeze
47
+
48
+ # Construct is an operator, a condition kind or a serve kind of a
49
+ # ruleset that this evaluator does not know. kind is :operator,
50
+ # :condition_kind or :serve_kind.
51
+ Construct = Struct.new(:kind, :name) do
52
+ def as_json
53
+ { "kind" => kind.to_s, "name" => name }
54
+ end
55
+ end
56
+
57
+ # Result is the outcome of evaluating one flag for one context. Its
58
+ # as_json form is what the conformance fixtures compare, key for key.
59
+ #
60
+ # - type: the flag's type, and automatic_exposure its setting: with it
61
+ # an SDK emits an exposure after every evaluation whose result carries
62
+ # an experiment, with no second call. Both are the flag's rather than
63
+ # the evaluation's and are nil only when there is no flag to read them
64
+ # from, an error with flag_not_found.
65
+ # - value, variation: the served variation; nil for errors.
66
+ # - reason: one of REASONS.
67
+ # - rule_id: the rule that served, for reasons rule, rollout and
68
+ # experiment.
69
+ # - experiment: an ExperimentDetail with reason experiment, and with
70
+ # reason override when the override's shadow evaluation lands in a
71
+ # running experiment that includes the unit.
72
+ # - prerequisite: the flag-level prerequisite that failed, for reason
73
+ # prerequisite_failed.
74
+ # - bucket: the unit's bucket for reason rollout.
75
+ # - error_code: one of ERROR_CODES, for reason error.
76
+ # - override_influenced: an override decided the result, or any
77
+ # prerequisite sub-evaluation consulted on the way was
78
+ # override-influenced, whatever the final reason. Every exposure and
79
+ # event derived from the result carries it.
80
+ Result = Struct.new(:type, :automatic_exposure, :value, :variation, :reason, :rule_id, :experiment,
81
+ :prerequisite, :bucket, :error_code, :override_influenced, keyword_init: true) do
82
+ def initialize(override_influenced: false, **fields)
83
+ super
84
+ freeze
85
+ end
86
+
87
+ # of_flag is the result stamped with the flag it is about: every step
88
+ # leaves type and automatic_exposure alone, so one place fills them in
89
+ # for every reason a known flag can carry.
90
+ def of_flag(flag)
91
+ Result.new(**to_h, type: flag.type, automatic_exposure: flag.automatic_exposure)
92
+ end
93
+ private :of_flag
94
+
95
+ # as_json is the result's JSON form, keyed as the reference
96
+ # evaluator encodes it: the flag's type and automatic exposure unless
97
+ # there is no flag, the served value unless the reason is error, and
98
+ # every other field unless it is unset or an empty string.
99
+ def as_json
100
+ out = type.nil? ? {} : { "type" => type }
101
+ out["value"] = value unless reason == :error
102
+ {
103
+ "variation" => variation, "reason" => reason.to_s, "rule_id" => rule_id
104
+ }.each { |k, v| out[k] = v unless v.nil? || v == "" }
105
+ out["automatic_exposure"] = automatic_exposure unless type.nil?
106
+ {
107
+ "experiment" => experiment&.as_json, "prerequisite" => prerequisite,
108
+ "bucket" => bucket, "error_code" => error_code&.to_s
109
+ }.each { |k, v| out[k] = v unless v.nil? || v == "" }
110
+ out["override_influenced"] = override_influenced
111
+ out
112
+ end
113
+ end
114
+
115
+ # ExperimentDetail is everything an SDK needs to report an exposure: the
116
+ # one shape of experiment detail shared by local and remote evaluation.
117
+ # variation is the served variation: the assignment, or the override's
118
+ # variation when an override decided the result. override equals the
119
+ # result's override_influenced; exposures carrying it are excluded from
120
+ # analysis.
121
+ ExperimentDetail = Struct.new(:key, :variation, :unit_type, :unit_id, :inclusion_salt, :variation_salt,
122
+ :inclusion_bucket, :variation_bucket, :override, keyword_init: true) do
123
+ def initialize(**fields)
124
+ super
125
+ freeze
126
+ end
127
+
128
+ def as_json
129
+ to_h.transform_keys(&:to_s)
130
+ end
131
+ end
132
+
133
+ # The ruleset document, as decoded. Every struct is frozen.
134
+ Environment = Struct.new(:key, :protected, keyword_init: true)
135
+ Attribute = Struct.new(:key, :type, keyword_init: true)
136
+ Segment = Struct.new(:key, :private, :conditions, keyword_init: true)
137
+ Condition = Struct.new(:kind, :attribute, :op, :value, :segment, :flag, :variation, keyword_init: true)
138
+ Flag = Struct.new(:key, :type, :automatic_exposure, :variations, :on, :off_variation,
139
+ :fallthrough, :prerequisites, :overrides, :rules, keyword_init: true)
140
+ Variation = Struct.new(:key, :value, keyword_init: true)
141
+ Prerequisite = Struct.new(:flag, :variation, keyword_init: true)
142
+ Override = Struct.new(:unit_type, :unit_id, :variation, keyword_init: true)
143
+ Rule = Struct.new(:id, :conditions, :serve, keyword_init: true)
144
+ Serve = Struct.new(:kind, :variation, :unit_type, :percentage, :salt, :experiment, :inclusion_salt,
145
+ :variation_salt, :traffic_allocation, :weights, keyword_init: true)
146
+ Weight = Struct.new(:variation, :weight, keyword_init: true)
147
+ end
148
+ end