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 +7 -0
- data/LICENSE +21 -0
- data/README.md +52 -0
- data/lib/feat/bucketing.rb +36 -0
- data/lib/feat/client.rb +128 -0
- data/lib/feat/context.rb +66 -0
- data/lib/feat/datafile.rb +104 -0
- data/lib/feat/eval.rb +103 -0
- data/lib/feat/operators.rb +177 -0
- data/lib/feat/segments.rb +32 -0
- data/lib/feat/version.rb +3 -0
- data/lib/feat.rb +14 -0
- metadata +56 -0
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
|
data/lib/feat/client.rb
ADDED
|
@@ -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
|
data/lib/feat/context.rb
ADDED
|
@@ -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
|
data/lib/feat/version.rb
ADDED
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: []
|