shipeasy-sdk 1.0.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/README.md +61 -0
- data/lib/shipeasy/sdk/eval.rb +117 -0
- data/lib/shipeasy/sdk/flags_client.rb +169 -0
- data/lib/shipeasy/sdk/murmur3.rb +54 -0
- data/lib/shipeasy/sdk/version.rb +5 -0
- data/lib/shipeasy-sdk.rb +12 -0
- metadata +47 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4eafaff1d5be1131bb55a64dbbb1653ee18f2a5a9e22cd83a45bb92de7bcfcb6
|
|
4
|
+
data.tar.gz: 4b59ebf58017deb5def949f46235d53f6ca60320400ac72ada320eff92d7e5d5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cb3f19e0215c65fed6db82a1ef181949ef0cb666a25e02e67103b6840bc79ee3e855e9f784a99f03d9ca54c73a74f431e3d16fd90fca29561317ab4b80b200f0
|
|
7
|
+
data.tar.gz: 480ac5a6fdeaef418e34488a668e4431bafa392a54d7e8eae7373541a48d401bdb687408e0759f6269c7263c9d38a11b4b4877930df65b4cd7bde859472b0eca
|
data/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# shipeasy-sdk
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the ShipEasy experiment platform. Evaluates feature flags and A/B experiments locally against blobs polled from the ShipEasy edge worker.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "shipeasy-sdk"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
require "shipeasy-sdk"
|
|
16
|
+
|
|
17
|
+
client = Shipeasy::SDK.new_client(api_key: "sdk_live_xxxxx")
|
|
18
|
+
client.init # fetches flags/experiments + starts background polling thread
|
|
19
|
+
|
|
20
|
+
user = { user_id: "usr_123", plan: "pro", country: "US" }
|
|
21
|
+
|
|
22
|
+
# Feature flag
|
|
23
|
+
if client.get_flag("new_checkout", user)
|
|
24
|
+
# show new checkout
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Remote config
|
|
28
|
+
color = client.get_config("button_color") # => "blue" (raw value)
|
|
29
|
+
|
|
30
|
+
# A/B experiment
|
|
31
|
+
result = client.get_experiment("checkout_cta", user, { label: "Buy now" })
|
|
32
|
+
puts result.in_experiment # true/false
|
|
33
|
+
puts result.group # "control" | "treatment"
|
|
34
|
+
puts result.params # { "label" => "Checkout" }
|
|
35
|
+
|
|
36
|
+
# Track a metric event (fire-and-forget background thread)
|
|
37
|
+
client.track("usr_123", "checkout_completed", { revenue: 49.99 })
|
|
38
|
+
|
|
39
|
+
# Shutdown (stops background poll thread)
|
|
40
|
+
client.destroy
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## init vs init_once
|
|
44
|
+
|
|
45
|
+
- `init` — fetches data, marks initialized, starts the background poll thread. Call once at app boot.
|
|
46
|
+
- `init_once` — same as `init` but is a no-op if already initialized. Safe to call multiple times (e.g. in middleware).
|
|
47
|
+
|
|
48
|
+
## Evaluation details
|
|
49
|
+
|
|
50
|
+
- **Gates** — rules matched in order; rollout bucket computed as `murmur3("#{salt}:#{uid}") % 10000 < rolloutPct`.
|
|
51
|
+
- **Experiments** — checks `status == "running"`, optional targeting gate, universe holdout range, allocation bucket, then group assignment by weight.
|
|
52
|
+
- **MurmurHash3** — pure Ruby implementation, x86_32 variant, seed 0.
|
|
53
|
+
- **ETag caching** — each poll sends `If-None-Match`; a `304` response skips the JSON parse.
|
|
54
|
+
- **Poll interval** — initial default 30 s; overridden by `X-Poll-Interval` response header from the flags endpoint.
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
| Parameter | Default | Description |
|
|
59
|
+
|------------|--------------------------------|-----------------------------------|
|
|
60
|
+
| `api_key` | (required) | SDK key from the ShipEasy dashboard |
|
|
61
|
+
| `base_url` | `https://edge.shipeasy.dev` | Override for local dev / staging |
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require_relative "murmur3"
|
|
2
|
+
|
|
3
|
+
module Shipeasy
|
|
4
|
+
module SDK
|
|
5
|
+
module Eval
|
|
6
|
+
def self.murmur3(key)
|
|
7
|
+
Murmur3.hash32(key, 0)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.enabled?(v)
|
|
11
|
+
v == 1 || v == true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.to_num(v)
|
|
15
|
+
case v
|
|
16
|
+
when Numeric then v.finite? ? v : nil
|
|
17
|
+
when String
|
|
18
|
+
n = Float(v) rescue nil
|
|
19
|
+
n&.finite? ? n : nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.match_rule(rule, user)
|
|
24
|
+
attr = rule["attr"] || rule[:attr]
|
|
25
|
+
op = rule["op"] || rule[:op]
|
|
26
|
+
value = rule["value"] || rule[:value]
|
|
27
|
+
actual = user[attr] || user[attr.to_sym]
|
|
28
|
+
|
|
29
|
+
case op
|
|
30
|
+
when "eq" then actual == value
|
|
31
|
+
when "neq" then actual != value
|
|
32
|
+
when "in" then Array(value).include?(actual)
|
|
33
|
+
when "not_in" then !Array(value).include?(actual)
|
|
34
|
+
when "contains"
|
|
35
|
+
if actual.is_a?(String) && value.is_a?(String)
|
|
36
|
+
actual.include?(value)
|
|
37
|
+
elsif actual.is_a?(Array)
|
|
38
|
+
actual.include?(value)
|
|
39
|
+
else
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
when "regex"
|
|
43
|
+
actual.is_a?(String) && value.is_a?(String) &&
|
|
44
|
+
Regexp.new(value).match?(actual) rescue false
|
|
45
|
+
when "gt", "gte", "lt", "lte"
|
|
46
|
+
a = to_num(actual)
|
|
47
|
+
b = to_num(value)
|
|
48
|
+
return false if a.nil? || b.nil?
|
|
49
|
+
case op
|
|
50
|
+
when "gt" then a > b
|
|
51
|
+
when "gte" then a >= b
|
|
52
|
+
when "lt" then a < b
|
|
53
|
+
when "lte" then a <= b
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.eval_gate(gate, user)
|
|
61
|
+
return false if enabled?(gate["killswitch"])
|
|
62
|
+
return false unless enabled?(gate["enabled"])
|
|
63
|
+
|
|
64
|
+
(gate["rules"] || []).each do |rule|
|
|
65
|
+
return false unless match_rule(rule, user)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
|
|
69
|
+
return false unless uid
|
|
70
|
+
|
|
71
|
+
salt = gate["salt"] || gate[:salt]
|
|
72
|
+
murmur3("#{salt}:#{uid}") % 10000 < (gate["rolloutPct"] || gate[:rolloutPct] || 0)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
ExperimentResult = Struct.new(:in_experiment, :group, :params, keyword_init: true)
|
|
76
|
+
|
|
77
|
+
def self.eval_experiment(exp, flags_blob, exps_blob, user)
|
|
78
|
+
not_in = ExperimentResult.new(in_experiment: false, group: "control", params: nil)
|
|
79
|
+
|
|
80
|
+
return not_in unless exp && exp["status"] == "running"
|
|
81
|
+
|
|
82
|
+
targeting_gate = exp["targetingGate"]
|
|
83
|
+
if targeting_gate && !targeting_gate.empty?
|
|
84
|
+
gate = flags_blob&.dig("gates", targeting_gate)
|
|
85
|
+
return not_in unless gate && eval_gate(gate, user)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
|
|
89
|
+
return not_in unless uid
|
|
90
|
+
|
|
91
|
+
universe_name = exp["universe"]
|
|
92
|
+
universe = exps_blob&.dig("universes", universe_name)
|
|
93
|
+
holdout = universe&.dig("holdout_range")
|
|
94
|
+
if holdout
|
|
95
|
+
seg = murmur3("#{universe_name}:#{uid}") % 10000
|
|
96
|
+
return not_in if seg >= holdout[0] && seg <= holdout[1]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
salt = exp["salt"]
|
|
100
|
+
allocation_pct = exp["allocationPct"] || 0
|
|
101
|
+
return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
|
|
102
|
+
|
|
103
|
+
group_hash = murmur3("#{salt}:group:#{uid}") % 10000
|
|
104
|
+
cumulative = 0
|
|
105
|
+
groups = exp["groups"] || []
|
|
106
|
+
groups.each_with_index do |g, i|
|
|
107
|
+
cumulative += g["weight"]
|
|
108
|
+
if group_hash < cumulative || i == groups.length - 1
|
|
109
|
+
return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
not_in
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "thread"
|
|
5
|
+
require_relative "eval"
|
|
6
|
+
|
|
7
|
+
module Shipeasy
|
|
8
|
+
module SDK
|
|
9
|
+
class FlagsClient
|
|
10
|
+
DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, base_url: nil)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
|
|
15
|
+
@flags_blob = nil
|
|
16
|
+
@exps_blob = nil
|
|
17
|
+
@flags_etag = nil
|
|
18
|
+
@exps_etag = nil
|
|
19
|
+
@poll_interval = 30
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@timer = nil
|
|
22
|
+
@initialized = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def init
|
|
26
|
+
fetch_all
|
|
27
|
+
@initialized = true
|
|
28
|
+
start_poll
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def init_once
|
|
32
|
+
return if @initialized
|
|
33
|
+
fetch_all
|
|
34
|
+
@initialized = true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def destroy
|
|
38
|
+
@timer&.kill
|
|
39
|
+
@timer = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def get_flag(name, user)
|
|
43
|
+
gate = @mutex.synchronize { @flags_blob&.dig("gates", name) }
|
|
44
|
+
return false unless gate
|
|
45
|
+
Eval.eval_gate(gate, user.transform_keys(&:to_s))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get_config(name, decode = nil)
|
|
49
|
+
entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
|
|
50
|
+
return nil unless entry
|
|
51
|
+
value = entry["value"]
|
|
52
|
+
decode ? decode.call(value) : value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_experiment(name, user, default_params, decode = nil)
|
|
56
|
+
flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
|
|
57
|
+
exp = exps_blob&.dig("experiments", name)
|
|
58
|
+
result = Eval.eval_experiment(exp, flags_blob, exps_blob, user.transform_keys(&:to_s))
|
|
59
|
+
result.params ||= default_params
|
|
60
|
+
|
|
61
|
+
if result.in_experiment && decode
|
|
62
|
+
begin
|
|
63
|
+
result = Eval::ExperimentResult.new(
|
|
64
|
+
in_experiment: true,
|
|
65
|
+
group: result.group,
|
|
66
|
+
params: decode.call(result.params),
|
|
67
|
+
)
|
|
68
|
+
rescue => e
|
|
69
|
+
warn "[shipeasy] get_experiment('#{name}') decode failed: #{e.message}"
|
|
70
|
+
return Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def track(user_id, event_name, props = {})
|
|
78
|
+
payload = JSON.generate({
|
|
79
|
+
events: [{
|
|
80
|
+
type: "metric",
|
|
81
|
+
event_name: event_name,
|
|
82
|
+
user_id: user_id.to_s,
|
|
83
|
+
ts: (Time.now.to_f * 1000).to_i,
|
|
84
|
+
**(props.empty? ? {} : { properties: props }),
|
|
85
|
+
}],
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
Thread.new do
|
|
89
|
+
post("/collect", payload)
|
|
90
|
+
rescue => e
|
|
91
|
+
warn "[shipeasy] track failed: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def start_poll
|
|
98
|
+
@timer = Thread.new do
|
|
99
|
+
loop do
|
|
100
|
+
sleep(@poll_interval)
|
|
101
|
+
begin
|
|
102
|
+
fetch_all
|
|
103
|
+
rescue => e
|
|
104
|
+
warn "[shipeasy] background poll failed: #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
@timer.abort_on_exception = false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def fetch_all
|
|
112
|
+
flags_thread = Thread.new { fetch_flags }
|
|
113
|
+
fetch_exps
|
|
114
|
+
interval = flags_thread.value
|
|
115
|
+
if interval && interval != @poll_interval
|
|
116
|
+
@poll_interval = interval
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fetch_flags
|
|
121
|
+
headers = { "X-SDK-Key" => @api_key }
|
|
122
|
+
headers["If-None-Match"] = @flags_etag if @flags_etag
|
|
123
|
+
res = http_get("/sdk/flags", headers)
|
|
124
|
+
interval = (res["X-Poll-Interval"] || "30").to_i
|
|
125
|
+
return interval if res.code == "304"
|
|
126
|
+
raise "GET /sdk/flags returned #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
127
|
+
etag = res["ETag"]
|
|
128
|
+
blob = JSON.parse(res.body)
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
@flags_etag = etag if etag
|
|
131
|
+
@flags_blob = blob
|
|
132
|
+
end
|
|
133
|
+
interval
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fetch_exps
|
|
137
|
+
headers = { "X-SDK-Key" => @api_key }
|
|
138
|
+
headers["If-None-Match"] = @exps_etag if @exps_etag
|
|
139
|
+
res = http_get("/sdk/experiments", headers)
|
|
140
|
+
return if res.code == "304"
|
|
141
|
+
raise "GET /sdk/experiments returned #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
142
|
+
etag = res["ETag"]
|
|
143
|
+
blob = JSON.parse(res.body)
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
@exps_etag = etag if etag
|
|
146
|
+
@exps_blob = blob
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def http_get(path, headers = {})
|
|
151
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
152
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
153
|
+
http.use_ssl = (uri.scheme == "https")
|
|
154
|
+
http.open_timeout = 5
|
|
155
|
+
http.read_timeout = 10
|
|
156
|
+
http.get(uri.request_uri, headers)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def post(path, body)
|
|
160
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
161
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
162
|
+
http.use_ssl = (uri.scheme == "https")
|
|
163
|
+
http.open_timeout = 5
|
|
164
|
+
http.read_timeout = 10
|
|
165
|
+
http.post(uri.request_uri, body, { "X-SDK-Key" => @api_key, "Content-Type" => "text/plain" })
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Shipeasy
|
|
2
|
+
module SDK
|
|
3
|
+
module Murmur3
|
|
4
|
+
C1 = 0xcc9e2d51
|
|
5
|
+
C2 = 0x1b873593
|
|
6
|
+
MASK32 = 0xFFFFFFFF
|
|
7
|
+
|
|
8
|
+
def self.hash32(key, seed = 0)
|
|
9
|
+
bytes = key.encode("UTF-8").bytes
|
|
10
|
+
len = bytes.length
|
|
11
|
+
h1 = seed
|
|
12
|
+
|
|
13
|
+
(len / 4).times do |i|
|
|
14
|
+
off = i * 4
|
|
15
|
+
k1 = bytes[off] | (bytes[off + 1] << 8) | (bytes[off + 2] << 16) | (bytes[off + 3] << 24)
|
|
16
|
+
k1 = (k1 & MASK32)
|
|
17
|
+
k1 = ((k1 * C1) & MASK32)
|
|
18
|
+
k1 = ((k1 << 15) | (k1 >> 17)) & MASK32
|
|
19
|
+
k1 = ((k1 * C2) & MASK32)
|
|
20
|
+
h1 ^= k1
|
|
21
|
+
h1 = ((h1 << 13) | (h1 >> 19)) & MASK32
|
|
22
|
+
h1 = ((h1 * 5) & MASK32)
|
|
23
|
+
h1 = (h1 + 0xe6546b64) & MASK32
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
tail = (len / 4) * 4
|
|
27
|
+
k1 = 0
|
|
28
|
+
remain = len & 3
|
|
29
|
+
k1 ^= (bytes[tail + 2] << 16) if remain >= 3
|
|
30
|
+
k1 ^= (bytes[tail + 1] << 8) if remain >= 2
|
|
31
|
+
k1 ^= bytes[tail] if remain >= 1
|
|
32
|
+
if remain > 0
|
|
33
|
+
k1 = (k1 * C1) & MASK32
|
|
34
|
+
k1 = ((k1 << 15) | (k1 >> 17)) & MASK32
|
|
35
|
+
k1 = (k1 * C2) & MASK32
|
|
36
|
+
h1 ^= k1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
h1 ^= len
|
|
40
|
+
h1 = fmix32(h1)
|
|
41
|
+
h1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.fmix32(h)
|
|
45
|
+
h ^= (h >> 16)
|
|
46
|
+
h = (h * 0x85ebca6b) & MASK32
|
|
47
|
+
h ^= (h >> 13)
|
|
48
|
+
h = (h * 0xc2b2ae35) & MASK32
|
|
49
|
+
h ^= (h >> 16)
|
|
50
|
+
h
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/shipeasy-sdk.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative "shipeasy/sdk/version"
|
|
2
|
+
require_relative "shipeasy/sdk/murmur3"
|
|
3
|
+
require_relative "shipeasy/sdk/eval"
|
|
4
|
+
require_relative "shipeasy/sdk/flags_client"
|
|
5
|
+
|
|
6
|
+
module Shipeasy
|
|
7
|
+
module SDK
|
|
8
|
+
def self.new_client(api_key:, base_url: nil)
|
|
9
|
+
FlagsClient.new(api_key: api_key, base_url: base_url)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shipeasy-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ShipEasy
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Server SDK for ShipEasy — polls /sdk/flags and /sdk/experiments, evaluates
|
|
13
|
+
flags and experiments locally.
|
|
14
|
+
email:
|
|
15
|
+
- sdk@shipeasy.dev
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/shipeasy-sdk.rb
|
|
22
|
+
- lib/shipeasy/sdk/eval.rb
|
|
23
|
+
- lib/shipeasy/sdk/flags_client.rb
|
|
24
|
+
- lib/shipeasy/sdk/murmur3.rb
|
|
25
|
+
- lib/shipeasy/sdk/version.rb
|
|
26
|
+
homepage: https://github.com/shipeasy/sdk-ruby
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata: {}
|
|
30
|
+
rdoc_options: []
|
|
31
|
+
require_paths:
|
|
32
|
+
- lib
|
|
33
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
34
|
+
requirements:
|
|
35
|
+
- - ">="
|
|
36
|
+
- !ruby/object:Gem::Version
|
|
37
|
+
version: 2.7.0
|
|
38
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '0'
|
|
43
|
+
requirements: []
|
|
44
|
+
rubygems_version: 4.0.6
|
|
45
|
+
specification_version: 4
|
|
46
|
+
summary: ShipEasy feature flag and experimentation SDK for Ruby
|
|
47
|
+
test_files: []
|