feat-sdk 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f64fac3df2e3ba9d2dc9db569472dd8329bea2d4fb4f2117d44e65a45d9e652
4
+ data.tar.gz: 4d60e7877bc7df301aa90a96bd19b11c211d6941387ffe834793c84db04ad353
5
+ SHA512:
6
+ metadata.gz: 2a990fc8a48b6176329fc664001c314b37fd5523122d91481700f804df7ec0d75f9705167eced27696627de0bfa25b895e0950b7aa69dd2d73785d917b7175b3
7
+ data.tar.gz: 8bbbed51ad214a26652743a15f036ea1936f431d74606c634f63282bfaaaee67310606e9110748a46385b9fa5786e441a6cd3690666a146c924ba4324420a1f2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 feat HQ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # feat-sdk
2
+
3
+ Server-side Ruby SDK for [feat](https://feat.so) feature flags. Local flag evaluation against a polled datafile. Standard library only - no gem dependencies.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem "feat-sdk"
10
+ ```
11
+
12
+ ```bash
13
+ bundle install
14
+ ```
15
+
16
+ Ruby 3.0+.
17
+
18
+ ## Usage
19
+
20
+ ```ruby
21
+ require "feat"
22
+
23
+ client = Feat::Client.new(
24
+ api_key: ENV.fetch("FEAT_SERVER_KEY"),
25
+ data_plane_url: "https://data.feat.so",
26
+ )
27
+ client.start
28
+
29
+ ctx = {
30
+ targetingKey: "user-123",
31
+ user: { plan: "pro", email: "alice@example.com" },
32
+ }
33
+
34
+ if client.get_boolean_value("checkout-v2", false, ctx)
35
+ # ...
36
+ end
37
+
38
+ client.close
39
+ ```
40
+
41
+ Use a **server** API key (`feat_sdk_...`).
42
+
43
+ ## How it works
44
+
45
+ - Fetches a per-environment datafile and keeps it in memory.
46
+ - Polls every 30 seconds by default. ETag-aware via `If-None-Match`.
47
+ - Evaluation runs in-process: no per-flag network call.
48
+ - A background thread handles polling; `close` stops it cleanly.
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,36 @@
1
+ require "digest"
2
+
3
+ module Feat
4
+ # Deterministic bucketing for percentage rollouts. Algorithm matches
5
+ # @feathq/feat-eval (JS), feat-go-sdk (Go), and feat (Python)
6
+ # bit-for-bit:
7
+ #
8
+ # sha1(salt + "." + flag_key + "." + context_key)
9
+ # -> first 8 bytes as big-endian uint64
10
+ # -> shift right 4 (drop low bits) for exactly 60 bits
11
+ # -> modulo 100_000
12
+ module Bucketing
13
+ BUCKET_SCALE = 100_000
14
+
15
+ module_function
16
+
17
+ def bucket(salt:, flag_key:, context_key:)
18
+ digest = Digest::SHA1.digest("#{salt}.#{flag_key}.#{context_key}")
19
+ first8 = digest[0, 8].unpack1("Q>") # big-endian unsigned 64-bit
20
+ sixty = first8 >> 4
21
+ sixty % BUCKET_SCALE
22
+ end
23
+
24
+ # Walk cumulative weights; return the variation whose range contains
25
+ # bucket_value. Defensive fallback to the last variation if upstream
26
+ # weights underflow the scale.
27
+ def pick_by_weight(bucket_value, variations)
28
+ cumulative = 0
29
+ variations.each do |v|
30
+ cumulative += v.weight
31
+ return v.variationId if bucket_value < cumulative
32
+ end
33
+ variations.empty? ? nil : variations.last.variationId
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,128 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Feat
6
+ # Polling HTTP client. Uses stdlib only - zero gem dependencies.
7
+ class Client
8
+ DEFAULT_POLL_INTERVAL = 30.0
9
+ MIN_POLL_INTERVAL = 5.0
10
+ MAX_DATAFILE_BYTES = 10 * 1024 * 1024
11
+
12
+ def initialize(api_key:, data_plane_url:, poll_interval: DEFAULT_POLL_INTERVAL, http_client: nil)
13
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
14
+ raise ArgumentError, "data_plane_url is required" if data_plane_url.nil? || data_plane_url.empty?
15
+
16
+ assert_https_url!(data_plane_url)
17
+
18
+ @api_key = api_key
19
+ @data_plane_url = data_plane_url.chomp("/")
20
+ @poll_interval = [poll_interval.to_f, MIN_POLL_INTERVAL].max
21
+ @http_client = http_client
22
+ @datafile = nil
23
+ @etag = nil
24
+ @mutex = Mutex.new
25
+ @stop = false
26
+ @thread = nil
27
+ end
28
+
29
+ # Blocking initial fetch; spawns a background poller thread.
30
+ def start
31
+ refresh
32
+ @thread ||= Thread.new { poll_loop }
33
+ end
34
+
35
+ def close
36
+ @stop = true
37
+ end
38
+
39
+ def refresh
40
+ fetch_once
41
+ end
42
+
43
+ def evaluate(flag_key, default_value, ctx)
44
+ df = @datafile
45
+ if df.nil?
46
+ return EvaluationResult.new(
47
+ value: default_value, reason: Reason::ERROR,
48
+ error_message: "client not ready: call #start before #evaluate"
49
+ )
50
+ end
51
+ Eval.call(flag_key: flag_key, default_value: default_value, ctx: ctx, datafile: df)
52
+ end
53
+
54
+ def get_boolean_value(flag_key, default, ctx)
55
+ r = evaluate(flag_key, default, ctx)
56
+ r.value == true || r.value == false ? r.value : default
57
+ end
58
+
59
+ def get_string_value(flag_key, default, ctx)
60
+ r = evaluate(flag_key, default, ctx)
61
+ r.value.is_a?(String) ? r.value : default
62
+ end
63
+
64
+ def get_number_value(flag_key, default, ctx)
65
+ r = evaluate(flag_key, default, ctx)
66
+ r.value.is_a?(Numeric) && !(r.value == true || r.value == false) ? r.value : default
67
+ end
68
+
69
+ def get_object_value(flag_key, default, ctx)
70
+ r = evaluate(flag_key, default, ctx)
71
+ r.value
72
+ end
73
+
74
+ private
75
+
76
+ def assert_https_url!(url)
77
+ uri = URI.parse(url)
78
+ return if uri.scheme == "https"
79
+ return if uri.scheme == "http" && %w[localhost 127.0.0.1].include?(uri.host)
80
+ raise ArgumentError, "data_plane_url must use https:// (http://localhost allowed for tests)"
81
+ rescue URI::InvalidURIError
82
+ raise ArgumentError, "data_plane_url is not a valid URL"
83
+ end
84
+
85
+ def poll_loop
86
+ until @stop
87
+ sleep @poll_interval
88
+ break if @stop
89
+
90
+ begin
91
+ fetch_once
92
+ rescue StandardError
93
+ # Defensive: keep polling on any transient error.
94
+ end
95
+ end
96
+ end
97
+
98
+ def fetch_once
99
+ uri = URI.parse("#{@data_plane_url}/sdk/v1/datafile")
100
+ req = Net::HTTP::Get.new(uri)
101
+ req["Authorization"] = "Bearer #{@api_key}"
102
+ @mutex.synchronize { req["If-None-Match"] = @etag if @etag }
103
+
104
+ res = (@http_client || Net::HTTP).start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
105
+ http.request(req)
106
+ end
107
+
108
+ case res.code.to_i
109
+ when 304, 404
110
+ false
111
+ when 200
112
+ length = res["Content-Length"]&.to_i
113
+ raise "datafile exceeds maximum allowed size" if length && length > MAX_DATAFILE_BYTES
114
+ body = res.body
115
+ raise "datafile exceeds maximum allowed size" if body.bytesize > MAX_DATAFILE_BYTES
116
+ data = JSON.parse(body)
117
+ new_etag = res["ETag"]
118
+ @mutex.synchronize do
119
+ @datafile = Datafile.from_json(data)
120
+ @etag = new_etag if new_etag
121
+ end
122
+ true
123
+ else
124
+ raise "feat: fetch datafile failed: #{res.code}"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,66 @@
1
+ module Feat
2
+ # SDK-consumer-supplied context. Mirrors OpenFeature's pattern: a
3
+ # `targeting_key` shorthand for "user.key", and a `kinds` hash whose
4
+ # keys match the datafile's `contextKinds` map.
5
+ #
6
+ # Example:
7
+ #
8
+ # Feat::EvalContext.new(
9
+ # targeting_key: "user-123",
10
+ # kinds: {
11
+ # "user" => { "key" => "user-123", "email" => "u@example.com" },
12
+ # "organization" => { "key" => "acme", "plan" => "pro" }
13
+ # }
14
+ # )
15
+ EvalContext = Struct.new(:targeting_key, :kinds, keyword_init: true) do
16
+ def initialize(targeting_key: nil, kinds: {})
17
+ super
18
+ end
19
+ end
20
+
21
+ module ContextResolver
22
+ module_function
23
+
24
+ # Walks "user.email", "organization.plan", "user.address.city"
25
+ # against the EvalContext. Returns nil if any segment is missing
26
+ # — operators treat nil as a non-match.
27
+ def resolve_attribute(ctx, attribute_path)
28
+ return nil if attribute_path.nil? || attribute_path.empty?
29
+
30
+ parts = attribute_path.split(".", 2)
31
+ kind_obj = read_kind(ctx, parts[0])
32
+ return nil if kind_obj.nil?
33
+ return kind_obj["key"] if parts.length == 1
34
+
35
+ rest = parts[1]
36
+ cur = kind_obj
37
+ rest.split(".").each do |p|
38
+ return nil unless cur.is_a?(Hash)
39
+ return nil unless cur.key?(p)
40
+
41
+ cur = cur[p]
42
+ end
43
+ cur
44
+ end
45
+
46
+ def read_context_key(ctx, kind_key)
47
+ obj = read_kind(ctx, kind_key)
48
+ return nil if obj.nil?
49
+
50
+ key = obj["key"]
51
+ key.is_a?(String) ? key : nil
52
+ end
53
+
54
+ def read_kind(ctx, kind_key)
55
+ if kind_key == "user"
56
+ obj = ctx.kinds["user"]
57
+ return obj if obj.is_a?(Hash)
58
+ return { "key" => ctx.targeting_key } if ctx.targeting_key
59
+
60
+ return nil
61
+ end
62
+ obj = ctx.kinds[kind_key]
63
+ obj.is_a?(Hash) ? obj : nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,104 @@
1
+ module Feat
2
+ # Wire-format types. JSON field names mirror @feathq/datafile-schema
3
+ # exactly (camelCase) — `from_json` keeps the hash keys, no rekeying.
4
+ module Datafile
5
+ VariationSpec = Struct.new(:id, :name, :value, keyword_init: true)
6
+ TargetSpec = Struct.new(:contextKindKey, :contextKey, :variationId, keyword_init: true)
7
+ ConditionSpec = Struct.new(:attributePath, :operator, :values, keyword_init: true)
8
+ ConditionGroupSpec = Struct.new(:conditions, keyword_init: true)
9
+ RolloutVariation = Struct.new(:variationId, :weight, keyword_init: true)
10
+ Rollout = Struct.new(:bucketingContextKindKey, :variations, keyword_init: true)
11
+ RuleSpec = Struct.new(
12
+ :id, :bucketingContextKindKey, :variationId, :rollout, :groups,
13
+ keyword_init: true
14
+ )
15
+ FlagSpec = Struct.new(
16
+ :id, :key, :valueType, :salt, :archived, :isEnabled, :offVariationId,
17
+ :defaultVariationId, :defaultRollout, :defaultBucketingContextKindKey,
18
+ :variations, :targets, :rules,
19
+ keyword_init: true
20
+ )
21
+ SegmentRuleSpec = Struct.new(:conditions, keyword_init: true)
22
+ SegmentSpec = Struct.new(:key, :rules, keyword_init: true)
23
+ ContextKindSpec = Struct.new(
24
+ :key, :availableForRules, :availableForExperiments,
25
+ keyword_init: true
26
+ )
27
+
28
+ File = Struct.new(
29
+ :schemaVersion, :envId, :envKey, :projectId, :version, :etag,
30
+ :generatedAt, :flags, :segments, :contextKinds,
31
+ keyword_init: true
32
+ )
33
+
34
+ # Parse a wire-format hash (typically JSON.parse output) into a
35
+ # Datafile::File. Hash keys must already be string-keyed (default for
36
+ # JSON.parse).
37
+ def self.from_json(data)
38
+ File.new(
39
+ schemaVersion: data["schemaVersion"],
40
+ envId: data["envId"],
41
+ envKey: data["envKey"],
42
+ projectId: data["projectId"],
43
+ version: data["version"],
44
+ etag: data["etag"],
45
+ generatedAt: data["generatedAt"],
46
+ flags: data["flags"].each_with_object({}) { |(k, v), o| o[k] = build_flag(v) },
47
+ segments: (data["segments"] || {}).each_with_object({}) { |(k, v), o| o[k] = build_segment(v) },
48
+ contextKinds: (data["contextKinds"] || {}).each_with_object({}) { |(k, v), o| o[k] = ContextKindSpec.new(**symbolize(v)) }
49
+ )
50
+ end
51
+
52
+ def self.build_flag(d)
53
+ FlagSpec.new(
54
+ id: d["id"],
55
+ key: d["key"],
56
+ valueType: d["valueType"],
57
+ salt: d["salt"],
58
+ archived: d["archived"],
59
+ isEnabled: d["isEnabled"],
60
+ offVariationId: d["offVariationId"],
61
+ defaultVariationId: d["defaultVariationId"],
62
+ defaultRollout: build_rollout(d["defaultRollout"]),
63
+ defaultBucketingContextKindKey: d["defaultBucketingContextKindKey"],
64
+ variations: d["variations"].map { |v| VariationSpec.new(**symbolize(v)) },
65
+ targets: d["targets"].map { |t| TargetSpec.new(**symbolize(t)) },
66
+ rules: d["rules"].map { |r| build_rule(r) }
67
+ )
68
+ end
69
+
70
+ def self.build_rule(d)
71
+ RuleSpec.new(
72
+ id: d["id"],
73
+ bucketingContextKindKey: d["bucketingContextKindKey"],
74
+ variationId: d["variationId"],
75
+ rollout: build_rollout(d["rollout"]),
76
+ groups: d["groups"].map { |g|
77
+ ConditionGroupSpec.new(conditions: g["conditions"].map { |c| ConditionSpec.new(**symbolize(c)) })
78
+ }
79
+ )
80
+ end
81
+
82
+ def self.build_rollout(d)
83
+ return nil if d.nil?
84
+
85
+ Rollout.new(
86
+ bucketingContextKindKey: d["bucketingContextKindKey"],
87
+ variations: d["variations"].map { |v| RolloutVariation.new(**symbolize(v)) }
88
+ )
89
+ end
90
+
91
+ def self.build_segment(d)
92
+ SegmentSpec.new(
93
+ key: d["key"],
94
+ rules: d["rules"].map { |r|
95
+ SegmentRuleSpec.new(conditions: r["conditions"].map { |c| ConditionSpec.new(**symbolize(c)) })
96
+ }
97
+ )
98
+ end
99
+
100
+ def self.symbolize(h)
101
+ h.each_with_object({}) { |(k, v), o| o[k.to_sym] = v }
102
+ end
103
+ end
104
+ end
data/lib/feat/eval.rb ADDED
@@ -0,0 +1,103 @@
1
+ module Feat
2
+ # Evaluation result reasons. Match OpenFeature's enum + the
3
+ # cross-language SDK convention.
4
+ module Reason
5
+ TARGETING_MATCH = "TARGETING_MATCH".freeze
6
+ SPLIT = "SPLIT".freeze
7
+ FALLTHROUGH = "FALLTHROUGH".freeze
8
+ DEFAULT = "DEFAULT".freeze
9
+ DISABLED = "DISABLED".freeze
10
+ ERROR = "ERROR".freeze
11
+ STATIC = "STATIC".freeze
12
+ end
13
+
14
+ EvaluationResult = Struct.new(:value, :variation_id, :reason, :error_message, keyword_init: true) do
15
+ def initialize(value:, variation_id: nil, reason:, error_message: nil)
16
+ super
17
+ end
18
+ end
19
+
20
+ module Eval
21
+ module_function
22
+
23
+ # Run the precedence pipeline:
24
+ #
25
+ # 1. archived flag -> off variation DISABLED
26
+ # 2. !isEnabled -> off variation DISABLED
27
+ # 3. individual target -> target variation TARGETING_MATCH
28
+ # 4. first matching rule -> rule variation/rollout TARGETING_MATCH / SPLIT
29
+ # 5. default -> default variation/rollout FALLTHROUGH / SPLIT
30
+ # 6. nothing matched -> off variation DEFAULT
31
+ def call(flag_key:, default_value:, ctx:, datafile:)
32
+ flag = datafile.flags[flag_key]
33
+ if flag.nil?
34
+ return EvaluationResult.new(
35
+ value: default_value, reason: Reason::ERROR,
36
+ error_message: "flag could not be evaluated"
37
+ )
38
+ end
39
+
40
+ if flag.archived || !flag.isEnabled
41
+ return resolve_variation(flag, flag.offVariationId, Reason::DISABLED, default_value)
42
+ end
43
+
44
+ flag.targets.each do |target|
45
+ ctx_key = ContextResolver.read_context_key(ctx, target.contextKindKey)
46
+ if !ctx_key.nil? && ctx_key == target.contextKey
47
+ return resolve_variation(flag, target.variationId, Reason::TARGETING_MATCH, default_value)
48
+ end
49
+ end
50
+
51
+ flag.rules.each do |rule|
52
+ next unless match_rule?(rule, ctx, datafile)
53
+
54
+ if !rule.variationId.nil?
55
+ return resolve_variation(flag, rule.variationId, Reason::TARGETING_MATCH, default_value)
56
+ end
57
+ if !rule.rollout.nil?
58
+ picked = pick_rollout(flag, rule.rollout, ctx)
59
+ return resolve_variation(flag, picked, Reason::SPLIT, default_value) unless picked.nil?
60
+ end
61
+ end
62
+
63
+ if !flag.defaultVariationId.nil?
64
+ return resolve_variation(flag, flag.defaultVariationId, Reason::FALLTHROUGH, default_value)
65
+ end
66
+ if !flag.defaultRollout.nil?
67
+ picked = pick_rollout(flag, flag.defaultRollout, ctx)
68
+ return resolve_variation(flag, picked, Reason::SPLIT, default_value) unless picked.nil?
69
+ end
70
+
71
+ resolve_variation(flag, flag.offVariationId, Reason::DEFAULT, default_value)
72
+ end
73
+
74
+ def match_rule?(rule, ctx, datafile)
75
+ return false if rule.groups.empty?
76
+
77
+ rule.groups.any? do |group|
78
+ next false if group.conditions.empty?
79
+
80
+ group.conditions.all? { |cond| Segments.match_condition(cond, ctx, datafile) }
81
+ end
82
+ end
83
+
84
+ def pick_rollout(flag, rollout, ctx)
85
+ ctx_key = ContextResolver.read_context_key(ctx, rollout.bucketingContextKindKey)
86
+ return nil if ctx_key.nil?
87
+
88
+ b = Bucketing.bucket(salt: flag.salt, flag_key: flag.key, context_key: ctx_key)
89
+ Bucketing.pick_by_weight(b, rollout.variations)
90
+ end
91
+
92
+ def resolve_variation(flag, variation_id, reason, default_value)
93
+ v = flag.variations.find { |x| x.id == variation_id }
94
+ if v.nil?
95
+ return EvaluationResult.new(
96
+ value: default_value, reason: Reason::ERROR,
97
+ error_message: "flag could not be evaluated"
98
+ )
99
+ end
100
+ EvaluationResult.new(value: v.value, variation_id: variation_id, reason: reason)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,177 @@
1
+ require "date"
2
+ require "time"
3
+
4
+ module Feat
5
+ # Per-operator predicates. Defensive: type-mismatch / parse-failure
6
+ # returns false rather than raising — matches the JS engine's posture
7
+ # against malformed contexts at the edge.
8
+ #
9
+ # segment_match / segment_not_match are dispatched by the rule
10
+ # evaluator (they recurse into the datafile's segments map), not here.
11
+ module Operators
12
+ module_function
13
+
14
+ def match(operator, lhs, values)
15
+ case operator
16
+ when "is_one_of" then any_eq?(lhs, values)
17
+ when "is_not_one_of" then !any_eq?(lhs, values)
18
+ when "is_empty" then empty?(lhs)
19
+ when "is_not_empty" then !empty?(lhs)
20
+ when "contains" then string_any?(lhs, values) { |s, v| s.include?(v) }
21
+ when "does_not_contain"
22
+ return true unless lhs.is_a?(String)
23
+
24
+ !string_any?(lhs, values) { |s, v| s.include?(v) }
25
+ when "starts_with" then string_any?(lhs, values) { |s, v| s.start_with?(v) }
26
+ when "ends_with" then string_any?(lhs, values) { |s, v| s.end_with?(v) }
27
+ when "matches_regex"
28
+ return false unless lhs.is_a?(String)
29
+
30
+ any_string_value?(values) { |v|
31
+ next false unless safe_regex?(v)
32
+
33
+ begin
34
+ !!(lhs =~ Regexp.new(v))
35
+ rescue RegexpError
36
+ false
37
+ end
38
+ }
39
+ when "gt" then numeric_cmp(lhs, values) { |a, b| a > b }
40
+ when "gte" then numeric_cmp(lhs, values) { |a, b| a >= b }
41
+ when "lt" then numeric_cmp(lhs, values) { |a, b| a < b }
42
+ when "lte" then numeric_cmp(lhs, values) { |a, b| a <= b }
43
+ when "before" then date_cmp(lhs, values) { |a, b| a < b }
44
+ when "after" then date_cmp(lhs, values) { |a, b| a > b }
45
+ when "semver_eq" then semver_cmp(lhs, values) { |c| c.zero? }
46
+ when "semver_gt" then semver_cmp(lhs, values) { |c| c.positive? }
47
+ when "semver_gte" then semver_cmp(lhs, values) { |c| c >= 0 }
48
+ when "semver_lt" then semver_cmp(lhs, values) { |c| c.negative? }
49
+ when "semver_lte" then semver_cmp(lhs, values) { |c| c <= 0 }
50
+ when "segment_match", "segment_not_match"
51
+ false
52
+ else
53
+ false
54
+ end
55
+ end
56
+
57
+ def empty?(lhs)
58
+ lhs.nil? || lhs == ""
59
+ end
60
+
61
+ # JS-engine-compatible equality with string/number coercion.
62
+ def deep_eq(a, b)
63
+ return true if a == b
64
+
65
+ if a.is_a?(Numeric) && b.is_a?(String)
66
+ return a.to_s == b || float_eq(a, b)
67
+ end
68
+ if a.is_a?(String) && b.is_a?(Numeric)
69
+ return a == b.to_s || float_eq(b, a)
70
+ end
71
+
72
+ false
73
+ end
74
+
75
+ def float_eq(num, str)
76
+ Float(str) == num.to_f
77
+ rescue ArgumentError, TypeError
78
+ false
79
+ end
80
+
81
+ def any_eq?(lhs, values)
82
+ values.any? { |v| deep_eq(lhs, v) }
83
+ end
84
+
85
+ def string_any?(lhs, values)
86
+ return false unless lhs.is_a?(String)
87
+
88
+ values.any? { |v| v.is_a?(String) && yield(lhs, v) }
89
+ end
90
+
91
+ def any_string_value?(values)
92
+ values.any? { |v| v.is_a?(String) && yield(v) }
93
+ end
94
+
95
+ def numeric_cmp(lhs, values)
96
+ a = to_number(lhs)
97
+ return false if a.nil?
98
+
99
+ values.any? do |v|
100
+ b = to_number(v)
101
+ b && yield(a, b)
102
+ end
103
+ end
104
+
105
+ def to_number(x)
106
+ case x
107
+ when Numeric then x.to_f
108
+ when String
109
+ Float(x)
110
+ end
111
+ rescue ArgumentError, TypeError
112
+ nil
113
+ end
114
+
115
+ def date_cmp(lhs, values)
116
+ a = to_time(lhs)
117
+ return false if a.nil?
118
+
119
+ values.any? do |v|
120
+ b = to_time(v)
121
+ b && yield(a, b)
122
+ end
123
+ end
124
+
125
+ def to_time(x)
126
+ case x
127
+ when String then Time.iso8601(x)
128
+ when Numeric then Time.at(x.to_f / 1000.0)
129
+ end
130
+ rescue ArgumentError
131
+ nil
132
+ end
133
+
134
+ # ReDoS guard for matches_regex. Caps pattern length and rejects the
135
+ # most common catastrophic-backtracking shapes. False positives just
136
+ # turn the rule into a non-match, which is the safe default.
137
+ REDOS_SHAPES = /\([^)]*[+*][^)]*\)\s*[+*]|\([^)]*\|[^)]*\)\s*[+*]/
138
+
139
+ def safe_regex?(pattern)
140
+ return false if pattern.length > 512
141
+ return false if REDOS_SHAPES.match?(pattern)
142
+
143
+ true
144
+ end
145
+
146
+ SEMVER_RE = /\A(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?\z/
147
+
148
+ def parse_semver(x)
149
+ return nil unless x.is_a?(String)
150
+
151
+ m = x.strip.match(SEMVER_RE)
152
+ return nil if m.nil?
153
+
154
+ [m[1].to_i, m[2].to_i, m[3].to_i, m[4]]
155
+ end
156
+
157
+ def compare_semver(a, b)
158
+ 3.times { |i| return a[i] - b[i] if a[i] != b[i] }
159
+ ap, bp = a[3], b[3]
160
+ return 0 if ap == bp
161
+ return 1 if ap.nil?
162
+ return -1 if bp.nil?
163
+
164
+ ap <=> bp
165
+ end
166
+
167
+ def semver_cmp(lhs, values)
168
+ a = parse_semver(lhs)
169
+ return false if a.nil?
170
+
171
+ values.any? do |v|
172
+ b = parse_semver(v)
173
+ b && yield(compare_semver(a, b))
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,32 @@
1
+ module Feat
2
+ module Segments
3
+ module_function
4
+
5
+ # True iff context matches the segment. Unknown segment keys
6
+ # evaluate to false (never raise).
7
+ def match_segment(segment_key, ctx, datafile)
8
+ seg = datafile.segments[segment_key]
9
+ return false if seg.nil?
10
+
11
+ seg.rules.any? do |rule|
12
+ next false if rule.conditions.empty?
13
+
14
+ rule.conditions.all? { |cond| match_condition(cond, ctx, datafile) }
15
+ end
16
+ end
17
+
18
+ def match_condition(cond, ctx, datafile)
19
+ case cond.operator
20
+ when "segment_match"
21
+ keys = cond.values.select { |v| v.is_a?(String) }
22
+ keys.any? { |k| match_segment(k, ctx, datafile) }
23
+ when "segment_not_match"
24
+ keys = cond.values.select { |v| v.is_a?(String) }
25
+ !keys.any? { |k| match_segment(k, ctx, datafile) }
26
+ else
27
+ lhs = ContextResolver.resolve_attribute(ctx, cond.attributePath)
28
+ Operators.match(cond.operator, lhs, cond.values)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ module Feat
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/feat.rb ADDED
@@ -0,0 +1,14 @@
1
+ require "feat/version"
2
+ require "feat/datafile"
3
+ require "feat/context"
4
+ require "feat/bucketing"
5
+ require "feat/operators"
6
+ require "feat/segments"
7
+ require "feat/eval"
8
+ require "feat/client"
9
+
10
+ # Feat - feature-flag SDK for Ruby.
11
+ #
12
+ # Server-side evaluation against a locally-cached datafile.
13
+ module Feat
14
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: feat-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - feat HQ
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Server-side Ruby SDK for feat. Polls a per-environment datafile and evaluates
13
+ flags locally with no per-flag network call. Stdlib only.
14
+ email:
15
+ - engineering@feat.so
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/feat.rb
23
+ - lib/feat/bucketing.rb
24
+ - lib/feat/client.rb
25
+ - lib/feat/context.rb
26
+ - lib/feat/datafile.rb
27
+ - lib/feat/eval.rb
28
+ - lib/feat/operators.rb
29
+ - lib/feat/segments.rb
30
+ - lib/feat/version.rb
31
+ homepage: https://feat.so
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ homepage_uri: https://feat.so
36
+ source_code_uri: https://github.com/feathq/ruby-sdk
37
+ bug_tracker_uri: https://github.com/feathq/ruby-sdk/issues
38
+ changelog_uri: https://github.com/feathq/ruby-sdk/releases
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.6.9
54
+ specification_version: 4
55
+ summary: feat feature-flag SDK for Ruby (server-side, local evaluation)
56
+ test_files: []