featureflow 0.6.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +27 -1
- data/lib/featureflow/application.rb +28 -0
- data/lib/featureflow/client.rb +41 -4
- data/lib/featureflow/conditions.rb +81 -10
- data/lib/featureflow/configuration.rb +4 -0
- data/lib/featureflow/evaluate.rb +1 -1
- data/lib/featureflow/events_client.rb +11 -7
- data/lib/featureflow/polling_client.rb +8 -3
- data/lib/featureflow/rails/rails_client.rb +24 -5
- data/lib/featureflow/user_builder.rb +4 -4
- data/lib/featureflow/version.rb +1 -1
- data/lib/generators/featureflow_generator.rb +1 -1
- metadata +42 -32
- data/.gitignore +0 -2
- data/.rubocop.yml +0 -5
- data/Gemfile +0 -4
- data/Gemfile.lock +0 -53
- data/Rakefile +0 -6
- data/featureflow.gemspec +0 -38
- data/publish-gem.sh +0 -1
- data/test.rb +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5b0b34f2239d222c1899b7b7af000ab6ee80dc31c481076c73a3220a8f6e76b
|
|
4
|
+
data.tar.gz: fc7e045b04d49220ed536cd885aa7ecdcd63f85d22feed2b41fab7ca6ea434bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c7834c0bd38d21c36bdbfe588491916b4d3f99c50bfce8ab33a654849fb3d58920918eada5616fc168373792c8373e86f5fbb239c210d1ce213bdd1c643002d
|
|
7
|
+
data.tar.gz: 362fae11135765cb677f26ee3619b57b499bc569d4af61119add4410704dfdcebae7e79d4652314f9d8d185f34dff8502383b0cc9ebf4b81f2d6003f3ea1fb8f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
# Change log
|
|
2
|
+
## [0.8.0] - 2026-08-13
|
|
3
|
+
### Added
|
|
4
|
+
- `application` configuration option (falling back to the `FEATUREFLOW_APPLICATION`
|
|
5
|
+
environment variable): names this workload, sent as `X-Featureflow-Application` on
|
|
6
|
+
every request so the dashboard can attribute usage and flag evaluations per
|
|
7
|
+
application. Slug-validated client-side (case forgiven; an invalid value is dropped
|
|
8
|
+
with a warning and no header is sent).
|
|
9
|
+
|
|
10
|
+
## [0.7.0] - 2026-08-11
|
|
11
|
+
### Fixed
|
|
12
|
+
- Condition operators fail closed instead of raising into the host application: an
|
|
13
|
+
invalid `matches` regex, a non-string attribute against a string operator, or a
|
|
14
|
+
string/number type mismatch on a comparison operator is now a no-match, never an
|
|
15
|
+
exception.
|
|
16
|
+
- `before`/`after` compare parsed instants rather than strings, so rule values with
|
|
17
|
+
arbitrary UTC offsets compare correctly; date-only values are read as UTC midnight
|
|
18
|
+
on every host, not local midnight.
|
|
19
|
+
- Rollout bucketing hashed every user to the same bucket (`user['id']` vs
|
|
20
|
+
`user[:id]`); percentage splits now distribute correctly.
|
|
21
|
+
- `RailsClient#evaluate` and `UserBuilder#with_attributes` were broken; both work
|
|
22
|
+
again, and the Rails generator accepts current `sdk-srv-env-` keys.
|
|
23
|
+
### Added
|
|
24
|
+
- `Featureflow::Client.new(api_key: '...')` accepts a configuration Hash; unknown
|
|
25
|
+
options raise rather than being silently dropped.
|
|
26
|
+
- Implicit `featureflow.date` / `featureflow.hourofday` attributes are injected at
|
|
27
|
+
evaluation time, matching the other server SDKs.
|
|
28
|
+
### Changed
|
|
29
|
+
- Requires Ruby >= 3.1 (excon ~> 1.5).
|
|
30
|
+
- The packaged gem now contains only `lib/`, README, CHANGELOG and LICENSE.
|
|
31
|
+
|
|
2
32
|
## [0.6.0] - 2018-12-17
|
|
3
33
|
### Fixed
|
|
4
34
|
- Use User over Context matching all other SDKs
|
data/README.md
CHANGED
|
@@ -153,6 +153,22 @@ featureflow.evaluate('key-three', user).is? 'custom' # == true
|
|
|
153
153
|
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
#### Naming your application
|
|
157
|
+
|
|
158
|
+
Optionally tag this workload with an application name so the Featureflow dashboard can
|
|
159
|
+
attribute SDK usage and flag evaluations to it (Admin → SDKs, and the "Evaluated by"
|
|
160
|
+
panel on each feature's statistics tab):
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
featureflow = Featureflow::Client.new(api_key: FEATUREFLOW_SERVER_KEY,
|
|
164
|
+
application: 'checkout-api')
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The name is a slug — lowercase letters, numbers, `.`, `_` and `-`, at most 64
|
|
168
|
+
characters. An invalid value is dropped with a warning and no tag is sent. The
|
|
169
|
+
`FEATUREFLOW_APPLICATION` environment variable is used when the option is not set in
|
|
170
|
+
code.
|
|
171
|
+
|
|
156
172
|
#### Further documentation
|
|
157
173
|
Further documentation can be found [here](http://docs.featureflow.io/docs)
|
|
158
174
|
|
|
@@ -181,4 +197,14 @@ rvm install 2.5.1
|
|
|
181
197
|
rvm use --default 2.5.1
|
|
182
198
|
bundle install
|
|
183
199
|
ruby test.rb
|
|
184
|
-
```
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Manual test harness (example server)
|
|
203
|
+
|
|
204
|
+
To try the SDK against a real Featureflow environment without writing any code, run the small local harness in [examples/harness](examples/harness):
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
FEATUREFLOW_SERVER_KEY=sdk-srv-env-<your_key> bundle exec ruby examples/harness/server.rb
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Then open `http://127.0.0.1:3456/` to evaluate features in the browser, or hit `GET /api/evaluate?feature=<key>&userId=<id>` directly. See [examples/harness/README.md](examples/harness/README.md) for all endpoints and environment variables (including pointing at staging via `FEATUREFLOW_BASE_URL`).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Featureflow
|
|
2
|
+
# Validates the configured application tag, sent as the X-Featureflow-Application
|
|
3
|
+
# header on every request so the dashboard can attribute usage per workload.
|
|
4
|
+
#
|
|
5
|
+
# Contract slug (featureflow-client-sdk-testbed/CONTRACT.md): lowercase [a-z0-9._-],
|
|
6
|
+
# max 64 chars. The server sanitises defensively; the SDK validates strictly so a
|
|
7
|
+
# typo is a visible warning here rather than a silently mangled tag there.
|
|
8
|
+
class Application
|
|
9
|
+
PATTERN = /\A[a-z0-9._-]{1,64}\z/.freeze
|
|
10
|
+
|
|
11
|
+
# Case is forgiven (lowercased); anything else invalid is dropped with a warning
|
|
12
|
+
# and no X-Featureflow-Application header is sent.
|
|
13
|
+
def self.sanitise(raw)
|
|
14
|
+
return nil if raw.nil? || raw == ''
|
|
15
|
+
unless raw.is_a?(String)
|
|
16
|
+
Featureflow.logger.warn "ignoring application #{raw.inspect} - must be a string"
|
|
17
|
+
return nil
|
|
18
|
+
end
|
|
19
|
+
value = raw.strip.downcase
|
|
20
|
+
unless PATTERN.match?(value)
|
|
21
|
+
Featureflow.logger.warn "ignoring application #{raw.inspect} - must be lowercase " \
|
|
22
|
+
'a-z, 0-9, dot, underscore or hyphen, max 64 chars'
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/featureflow/client.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'featureflow/application'
|
|
1
3
|
require 'featureflow/evaluate'
|
|
2
4
|
require 'featureflow/polling_client'
|
|
3
5
|
require 'featureflow/events_client'
|
|
@@ -5,10 +7,14 @@ require 'featureflow/events_client'
|
|
|
5
7
|
module Featureflow
|
|
6
8
|
class Client
|
|
7
9
|
def initialize(configuration = nil)
|
|
8
|
-
@configuration = configuration
|
|
10
|
+
@configuration = build_configuration(configuration)
|
|
9
11
|
@configuration.validate!
|
|
10
12
|
@features = {}
|
|
11
13
|
|
|
14
|
+
# Optional workload name sent as X-Featureflow-Application on every request so
|
|
15
|
+
# usage and flag evaluations are attributable per application in the dashboard.
|
|
16
|
+
@application = Application.sanitise(@configuration.application)
|
|
17
|
+
|
|
12
18
|
Featureflow.logger.info 'initializing client'
|
|
13
19
|
|
|
14
20
|
@failover_variants = {}
|
|
@@ -19,7 +25,7 @@ module Featureflow
|
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
unless @configuration.disable_events
|
|
22
|
-
@events_client = EventsClient.new @configuration.event_endpoint, @configuration.api_key
|
|
28
|
+
@events_client = EventsClient.new @configuration.event_endpoint, @configuration.api_key, @application
|
|
23
29
|
@events_client.register_features @configuration.with_features
|
|
24
30
|
end
|
|
25
31
|
|
|
@@ -38,7 +44,8 @@ module Featureflow
|
|
|
38
44
|
@configuration.endpoint,
|
|
39
45
|
@configuration.api_key,
|
|
40
46
|
poll_interval: 10,
|
|
41
|
-
timeout: 30
|
|
47
|
+
timeout: 30,
|
|
48
|
+
application: @application
|
|
42
49
|
)
|
|
43
50
|
end
|
|
44
51
|
|
|
@@ -61,7 +68,16 @@ module Featureflow
|
|
|
61
68
|
user = UserBuilder.new(user).build if user.is_a?(String)
|
|
62
69
|
|
|
63
70
|
user = user.dup
|
|
64
|
-
|
|
71
|
+
# Implicit attributes injected at evaluation time (matching the Node SDK's
|
|
72
|
+
# EvaluateHelpers): featureflow.date/hourofday let rules target the current
|
|
73
|
+
# moment; sdk-server's partial evaluation defers these to the JS client, but
|
|
74
|
+
# server SDKs evaluate them locally against "now".
|
|
75
|
+
now = Time.now
|
|
76
|
+
user[:attributes] = user[:attributes].merge(
|
|
77
|
+
'featureflow.user.id' => user[:id],
|
|
78
|
+
'featureflow.date' => now.utc.iso8601,
|
|
79
|
+
'featureflow.hourofday' => now.hour
|
|
80
|
+
)
|
|
65
81
|
|
|
66
82
|
Evaluate.new(
|
|
67
83
|
feature_key: key,
|
|
@@ -72,5 +88,26 @@ module Featureflow
|
|
|
72
88
|
events_client: @events_client
|
|
73
89
|
)
|
|
74
90
|
end
|
|
91
|
+
|
|
92
|
+
# Accepts either a Featureflow::Configuration, or a Hash of configuration
|
|
93
|
+
# options — `Featureflow::Client.new(api_key: '...')` is the form the README
|
|
94
|
+
# leads with and the natural Ruby idiom. Passing nothing keeps using the
|
|
95
|
+
# process-wide Featureflow.configuration (which reads FEATUREFLOW_SERVER_KEY).
|
|
96
|
+
private def build_configuration(configuration)
|
|
97
|
+
return Featureflow.configuration if configuration.nil?
|
|
98
|
+
return configuration unless configuration.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
config = Configuration.new
|
|
101
|
+
configuration.each do |option, value|
|
|
102
|
+
setter = "#{option}="
|
|
103
|
+
# Fail loudly rather than silently dropping a mistyped option — a
|
|
104
|
+
# misspelt `endpoint:` would otherwise quietly poll production.
|
|
105
|
+
unless config.respond_to?(setter)
|
|
106
|
+
raise ArgumentError, "Unknown Featureflow configuration option: #{option}"
|
|
107
|
+
end
|
|
108
|
+
config.public_send(setter, value)
|
|
109
|
+
end
|
|
110
|
+
config
|
|
111
|
+
end
|
|
75
112
|
end
|
|
76
113
|
end
|
|
@@ -1,33 +1,104 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'time'
|
|
3
|
+
|
|
1
4
|
module Featureflow
|
|
2
5
|
class Conditions
|
|
6
|
+
# Mirrors the Node SDK's dateParse: dates compare by instant, not by string,
|
|
7
|
+
# so ISO values with different UTC offsets (e.g. "+04:00" vs "Z") compare correctly.
|
|
8
|
+
#
|
|
9
|
+
# A value carrying no timezone information is read as UTC, per the SDK contract
|
|
10
|
+
# ("A date-only value denotes UTC midnight"): the dashboard's date picker emits
|
|
11
|
+
# date-only values like "2026-07-03", and reading those as *local* midnight would
|
|
12
|
+
# make a scheduled rollout fire at a different instant on every host in a fleet.
|
|
13
|
+
# Values with an explicit offset or "Z" keep their instant, unshifted.
|
|
14
|
+
def self.to_epoch(value)
|
|
15
|
+
case value
|
|
16
|
+
when Time then value.to_f
|
|
17
|
+
when Numeric then value.to_f
|
|
18
|
+
when String
|
|
19
|
+
begin
|
|
20
|
+
parsed = Time.parse(value)
|
|
21
|
+
# Time.parse silently falls back to the process's timezone when the value
|
|
22
|
+
# carries no zone; adding back the offset it applied re-reads the same
|
|
23
|
+
# wall-clock components as UTC. Date._parse only reports :offset when the
|
|
24
|
+
# value actually stated a zone.
|
|
25
|
+
parsed += parsed.utc_offset unless Date._parse(value).key?(:offset)
|
|
26
|
+
parsed.to_f
|
|
27
|
+
rescue ArgumentError, TypeError, RangeError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Both operands must be strings for a string operator to mean anything.
|
|
34
|
+
def self.both_strings?(a, b)
|
|
35
|
+
a.is_a?(String) && b.is_a?(String)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Numeric-to-numeric or string-to-string only. Mixing the two raises in Ruby, and
|
|
39
|
+
# coercing would answer version comparisons wrongly.
|
|
40
|
+
def self.comparable?(a, b)
|
|
41
|
+
(a.is_a?(Numeric) && b.is_a?(Numeric)) || (a.is_a?(String) && b.is_a?(String))
|
|
42
|
+
end
|
|
43
|
+
|
|
3
44
|
def self.test(op, a, b)
|
|
4
45
|
case op
|
|
5
46
|
when 'equals'
|
|
6
47
|
a.eql? b[0]
|
|
48
|
+
# The string operators assume both sides are strings. A numeric attribute against
|
|
49
|
+
# `contains` raised NoMethodError straight into the host application — the same
|
|
50
|
+
# fail-closed violation as the invalid regex below, and reachable the same way,
|
|
51
|
+
# from a rule somebody typed into the dashboard. Python already guards these with
|
|
52
|
+
# an explicit type check; this brings Ruby into line. A type mismatch is a
|
|
53
|
+
# no-match, not an exception.
|
|
7
54
|
when 'contains'
|
|
8
|
-
a.include?
|
|
55
|
+
both_strings?(a, b[0]) && a.include?(b[0])
|
|
9
56
|
when 'startsWith'
|
|
10
|
-
a.start_with?
|
|
57
|
+
both_strings?(a, b[0]) && a.start_with?(b[0])
|
|
11
58
|
when 'endsWith'
|
|
12
|
-
a.end_with?
|
|
59
|
+
both_strings?(a, b[0]) && a.end_with?(b[0])
|
|
13
60
|
when 'matches'
|
|
14
|
-
a
|
|
61
|
+
# The pattern is untrusted configuration, not code: it comes from a targeting
|
|
62
|
+
# rule somebody typed into the dashboard, so a malformed one like "[unclosed"
|
|
63
|
+
# is a user's mistake rather than a bug in the SDK. The contract
|
|
64
|
+
# (featureflow-client-sdk-testbed/CONTRACT.md, "Operators") therefore requires
|
|
65
|
+
# an invalid regex to return false — a flag SDK must degrade rather than take
|
|
66
|
+
# the host application down with it.
|
|
67
|
+
#
|
|
68
|
+
# Only the regex failure is rescued, never everything: RegexpError for a
|
|
69
|
+
# pattern that will not compile, TypeError for a value that is not a string at
|
|
70
|
+
# all. A genuine bug anywhere else still surfaces.
|
|
71
|
+
begin
|
|
72
|
+
a.match? Regexp.new(b[0])
|
|
73
|
+
rescue RegexpError, TypeError
|
|
74
|
+
false
|
|
75
|
+
end
|
|
15
76
|
when 'in'
|
|
16
77
|
b.include? a
|
|
17
78
|
when 'notIn'
|
|
18
79
|
!b.include? a
|
|
80
|
+
# Comparing a String with a Numeric raises ArgumentError in Ruby, so an attribute
|
|
81
|
+
# whose type does not match the rule's took the host application down. The
|
|
82
|
+
# contract requires a numeric operator to return false on non-numeric input
|
|
83
|
+
# rather than coercing — "1.10.0" > "1.9.0" must not silently succeed — so a
|
|
84
|
+
# mismatch is simply a no-match. String-to-string comparison is kept, since Ruby
|
|
85
|
+
# orders strings meaningfully and the server behaves the same way.
|
|
19
86
|
when 'greaterThan'
|
|
20
|
-
a > b[0]
|
|
87
|
+
comparable?(a, b[0]) && a > b[0]
|
|
21
88
|
when 'greaterThanOrEqual'
|
|
22
|
-
a >= b[0]
|
|
89
|
+
comparable?(a, b[0]) && a >= b[0]
|
|
23
90
|
when 'lessThan'
|
|
24
|
-
a < b[0]
|
|
91
|
+
comparable?(a, b[0]) && a < b[0]
|
|
25
92
|
when 'lessThanOrEqual'
|
|
26
|
-
a <= b[0]
|
|
93
|
+
comparable?(a, b[0]) && a <= b[0]
|
|
27
94
|
when 'before'
|
|
28
|
-
|
|
95
|
+
a_time = to_epoch(a)
|
|
96
|
+
b_time = to_epoch(b[0])
|
|
97
|
+
!!(a_time && b_time && a_time < b_time)
|
|
29
98
|
when 'after'
|
|
30
|
-
|
|
99
|
+
a_time = to_epoch(a)
|
|
100
|
+
b_time = to_epoch(b[0])
|
|
101
|
+
!!(a_time && b_time && a_time > b_time)
|
|
31
102
|
else
|
|
32
103
|
false
|
|
33
104
|
end
|
|
@@ -5,12 +5,16 @@ class Featureflow::Configuration
|
|
|
5
5
|
attr_accessor :disable_events
|
|
6
6
|
attr_accessor :with_features
|
|
7
7
|
attr_accessor :logger
|
|
8
|
+
# Optional workload name for per-application usage attribution in the dashboard;
|
|
9
|
+
# sent as X-Featureflow-Application on every request. See Featureflow::Application.
|
|
10
|
+
attr_accessor :application
|
|
8
11
|
|
|
9
12
|
DEFAULT_ENDPOINT = 'https://app.featureflow.io'
|
|
10
13
|
DEFAULT_EVENT_ENDPOINT = 'https://events.featureflow.io'
|
|
11
14
|
|
|
12
15
|
def initialize
|
|
13
16
|
self.api_key = ENV["FEATUREFLOW_SERVER_KEY"]
|
|
17
|
+
self.application = ENV["FEATUREFLOW_APPLICATION"]
|
|
14
18
|
self.endpoint = DEFAULT_ENDPOINT
|
|
15
19
|
self.event_endpoint = DEFAULT_EVENT_ENDPOINT
|
|
16
20
|
self.disable_events = false
|
data/lib/featureflow/evaluate.rb
CHANGED
|
@@ -48,7 +48,7 @@ module Featureflow
|
|
|
48
48
|
|
|
49
49
|
@feature['rules'].each do |rule|
|
|
50
50
|
next unless EvaluateHelpers.rule_matches(rule, @user)
|
|
51
|
-
hash = EvaluateHelpers.calculate_hash(@salt, @feature['key'], @user[
|
|
51
|
+
hash = EvaluateHelpers.calculate_hash(@salt, @feature['key'], @user[:id])
|
|
52
52
|
variant_value = EvaluateHelpers.get_variant_value(hash)
|
|
53
53
|
return EvaluateHelpers.get_variant_split_key(rule['variantSplits'], variant_value)
|
|
54
54
|
end
|
|
@@ -7,9 +7,11 @@ module Featureflow
|
|
|
7
7
|
#LOCK = Mutex.new
|
|
8
8
|
|
|
9
9
|
class EventsClient
|
|
10
|
-
def initialize(url, api_key)
|
|
10
|
+
def initialize(url, api_key, application = nil)
|
|
11
11
|
@url = url
|
|
12
12
|
@api_key = api_key
|
|
13
|
+
# Already sanitised by the Client (see lib/featureflow/application.rb).
|
|
14
|
+
@application = application
|
|
13
15
|
@eventsQueue = Queue.new
|
|
14
16
|
@scheduler = start_scheduler
|
|
15
17
|
end
|
|
@@ -104,15 +106,17 @@ module Featureflow
|
|
|
104
106
|
=end
|
|
105
107
|
|
|
106
108
|
private def send_event(event_name, method, path, body)
|
|
109
|
+
headers = {
|
|
110
|
+
'Authorization' => "Bearer #{@api_key}",
|
|
111
|
+
'Content-Type' => 'application/json;charset=UTF-8',
|
|
112
|
+
'Accept' => 'Application/Json',
|
|
113
|
+
'X-Featureflow-Client' => 'RubyClient/' + Featureflow::VERSION
|
|
114
|
+
}
|
|
115
|
+
headers['X-Featureflow-Application'] = @application if @application
|
|
107
116
|
connection = Excon.new(@url)
|
|
108
117
|
response = connection.request(method: method,
|
|
109
118
|
path: path,
|
|
110
|
-
headers:
|
|
111
|
-
'Authorization' => "Bearer #{@api_key}",
|
|
112
|
-
'Content-Type' => 'application/json;charset=UTF-8',
|
|
113
|
-
'Accept' => 'Application/Json',
|
|
114
|
-
'X-Featureflow-Client' => 'RubyClient/' + Featureflow::VERSION
|
|
115
|
-
},
|
|
119
|
+
headers: headers,
|
|
116
120
|
omit_default_port: true,
|
|
117
121
|
body: JSON.generate(body))
|
|
118
122
|
if response.status >= 400
|
|
@@ -5,7 +5,8 @@ module Featureflow
|
|
|
5
5
|
class PollingClient
|
|
6
6
|
DEFAULT_OPTIONS = {
|
|
7
7
|
poll_interval: 30,
|
|
8
|
-
timeout: 30
|
|
8
|
+
timeout: 30,
|
|
9
|
+
application: nil
|
|
9
10
|
}.freeze
|
|
10
11
|
LOCK = Mutex.new
|
|
11
12
|
|
|
@@ -35,12 +36,16 @@ module Featureflow
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def load_features
|
|
38
|
-
|
|
39
|
+
headers = {
|
|
39
40
|
'Authorization' => "Bearer #{@api_key}",
|
|
40
41
|
'Accept' => 'Application/Json',
|
|
41
42
|
'If-None-Match' => @etag,
|
|
42
43
|
'X-Featureflow-Client' => 'RubyClient/' + Featureflow::VERSION
|
|
43
|
-
}
|
|
44
|
+
}
|
|
45
|
+
# Write-only telemetry: never affects the response, so it cannot fragment the CDN cache.
|
|
46
|
+
headers['X-Featureflow-Application'] = @options[:application] if @options[:application]
|
|
47
|
+
response = Excon.get(@url + + '/api/sdk/v1/features', headers: headers,
|
|
48
|
+
omit_default_port: true, read_timeout: @options[:timeout])
|
|
44
49
|
|
|
45
50
|
if response.status == 200
|
|
46
51
|
Featureflow.logger.debug "updating features"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'featureflow/client'
|
|
2
|
+
require 'featureflow/user_builder'
|
|
2
3
|
|
|
3
4
|
module Featureflow
|
|
4
5
|
class RailsClient
|
|
@@ -6,13 +7,31 @@ module Featureflow
|
|
|
6
7
|
@request = request
|
|
7
8
|
end
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
# `user` is whatever Client#evaluate accepts: a String user id, or a user
|
|
11
|
+
# Hash built with Featureflow::UserBuilder ({id:, attributes:}). The current
|
|
12
|
+
# request's ip and url are merged in as implicit attributes so targeting
|
|
13
|
+
# rules can match on them; anything the caller set explicitly wins.
|
|
14
|
+
def evaluate(key, user)
|
|
15
|
+
Featureflow.evaluate(key, with_request_attributes(user))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def with_request_attributes(user)
|
|
21
|
+
user = UserBuilder.new(user).build if user.is_a?(String)
|
|
22
|
+
# Anything else is left alone so Client#evaluate raises its own ArgumentError.
|
|
23
|
+
return user unless user.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
user.merge(attributes: request_attributes.merge(user[:attributes] || {}))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Attribute keys are Strings throughout the evaluation path (UserBuilder
|
|
29
|
+
# normalises them), so the request attributes are keyed as Strings too.
|
|
30
|
+
def request_attributes
|
|
31
|
+
{
|
|
11
32
|
'featureflow.ip' => @request.remote_ip,
|
|
12
33
|
'featureflow.url' => @request.original_url
|
|
13
34
|
}
|
|
14
|
-
context = {key: context, values: {}} if context.is_a?(String)
|
|
15
|
-
Featureflow.evaluate(key, key: context[:key], values: request_context_values.merge(context[:values]))
|
|
16
35
|
end
|
|
17
36
|
end
|
|
18
|
-
end
|
|
37
|
+
end
|
|
@@ -9,12 +9,12 @@ module Featureflow
|
|
|
9
9
|
|
|
10
10
|
def with_attributes(hash)
|
|
11
11
|
raise ArgumentError, 'Parameter hash must be a Hash' unless hash.is_a?(Hash)
|
|
12
|
-
|
|
12
|
+
normalized = {}
|
|
13
13
|
hash.each do |k, v|
|
|
14
|
-
raise ArgumentError, "Value for #{k} must be a valid 'primitive' JSON datatype" unless
|
|
15
|
-
|
|
14
|
+
raise ArgumentError, "Value for #{k} must be a valid 'primitive' JSON datatype" unless valid_attribute?(v)
|
|
15
|
+
normalized[k.to_s] = v
|
|
16
16
|
end
|
|
17
|
-
@attributes = @attributes.merge(
|
|
17
|
+
@attributes = @attributes.merge(normalized)
|
|
18
18
|
self
|
|
19
19
|
end
|
|
20
20
|
|
data/lib/featureflow/version.rb
CHANGED
|
@@ -9,7 +9,7 @@ class FeatureflowGenerator < Rails::Generators::Base
|
|
|
9
9
|
desc "Configures the featureflow with your API key"
|
|
10
10
|
|
|
11
11
|
def create_initializer_file
|
|
12
|
-
unless /^srv-env-[a-f0-9]{32}$/ =~ api_key
|
|
12
|
+
unless /^sdk-srv-env-[a-f0-9]{32}$/ =~ api_key
|
|
13
13
|
raise Thor::Error, "Invalid featureflow environment api key #{api_key.inspect}\nYou can find your environment api key on your featureflow dashboard at https://[APP-NAME].featureflow.io/"
|
|
14
14
|
end
|
|
15
15
|
|
metadata
CHANGED
|
@@ -1,85 +1,99 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: featureflow
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oliver Oldfield-Hodge
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-08-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
14
|
+
name: rake
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '13.0'
|
|
20
20
|
type: :development
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '13.0'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
28
|
+
name: rspec
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
33
|
+
version: '3.0'
|
|
34
34
|
type: :development
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
40
|
+
version: '3.0'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
42
|
+
name: cucumber
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
45
|
- - "~>"
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '
|
|
47
|
+
version: '11.0'
|
|
48
48
|
type: :development
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '
|
|
54
|
+
version: '11.0'
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
|
-
name:
|
|
56
|
+
name: webrick
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.8'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.8'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: debug
|
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
|
58
72
|
requirements:
|
|
59
73
|
- - "~>"
|
|
60
74
|
- !ruby/object:Gem::Version
|
|
61
|
-
version:
|
|
75
|
+
version: '1.9'
|
|
62
76
|
type: :development
|
|
63
77
|
prerelease: false
|
|
64
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
79
|
requirements:
|
|
66
80
|
- - "~>"
|
|
67
81
|
- !ruby/object:Gem::Version
|
|
68
|
-
version:
|
|
82
|
+
version: '1.9'
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
84
|
name: excon
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
|
72
86
|
requirements:
|
|
73
87
|
- - "~>"
|
|
74
88
|
- !ruby/object:Gem::Version
|
|
75
|
-
version:
|
|
89
|
+
version: '1.5'
|
|
76
90
|
type: :runtime
|
|
77
91
|
prerelease: false
|
|
78
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
93
|
requirements:
|
|
80
94
|
- - "~>"
|
|
81
95
|
- !ruby/object:Gem::Version
|
|
82
|
-
version:
|
|
96
|
+
version: '1.5'
|
|
83
97
|
description: Connects your server with Featureflow. Create features at https://featureflow.io.
|
|
84
98
|
email:
|
|
85
99
|
- contact@featureflow.io
|
|
@@ -87,16 +101,11 @@ executables: []
|
|
|
87
101
|
extensions: []
|
|
88
102
|
extra_rdoc_files: []
|
|
89
103
|
files:
|
|
90
|
-
- ".gitignore"
|
|
91
|
-
- ".rubocop.yml"
|
|
92
104
|
- CHANGELOG.md
|
|
93
|
-
- Gemfile
|
|
94
|
-
- Gemfile.lock
|
|
95
105
|
- LICENSE
|
|
96
106
|
- README.md
|
|
97
|
-
- Rakefile
|
|
98
|
-
- featureflow.gemspec
|
|
99
107
|
- lib/featureflow.rb
|
|
108
|
+
- lib/featureflow/application.rb
|
|
100
109
|
- lib/featureflow/client.rb
|
|
101
110
|
- lib/featureflow/conditions.rb
|
|
102
111
|
- lib/featureflow/configuration.rb
|
|
@@ -110,13 +119,15 @@ files:
|
|
|
110
119
|
- lib/featureflow/user_builder.rb
|
|
111
120
|
- lib/featureflow/version.rb
|
|
112
121
|
- lib/generators/featureflow_generator.rb
|
|
113
|
-
- publish-gem.sh
|
|
114
|
-
- test.rb
|
|
115
122
|
homepage: https://github.com/featureflow/featureflow-ruby-sdk
|
|
116
123
|
licenses:
|
|
117
|
-
-
|
|
118
|
-
metadata:
|
|
119
|
-
|
|
124
|
+
- Apache-2.0
|
|
125
|
+
metadata:
|
|
126
|
+
homepage_uri: https://github.com/featureflow/featureflow-ruby-sdk
|
|
127
|
+
source_code_uri: https://github.com/featureflow/featureflow-ruby-sdk
|
|
128
|
+
changelog_uri: https://github.com/featureflow/featureflow-ruby-sdk/blob/master/CHANGELOG.md
|
|
129
|
+
bug_tracker_uri: https://github.com/featureflow/featureflow-ruby-sdk/issues
|
|
130
|
+
post_install_message:
|
|
120
131
|
rdoc_options: []
|
|
121
132
|
require_paths:
|
|
122
133
|
- lib
|
|
@@ -124,16 +135,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
124
135
|
requirements:
|
|
125
136
|
- - ">="
|
|
126
137
|
- !ruby/object:Gem::Version
|
|
127
|
-
version: '
|
|
138
|
+
version: '3.1'
|
|
128
139
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
129
140
|
requirements:
|
|
130
141
|
- - ">="
|
|
131
142
|
- !ruby/object:Gem::Version
|
|
132
143
|
version: '0'
|
|
133
144
|
requirements: []
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
signing_key:
|
|
145
|
+
rubygems_version: 3.5.22
|
|
146
|
+
signing_key:
|
|
137
147
|
specification_version: 4
|
|
138
148
|
summary: Ruby SDK for Featureflow
|
|
139
149
|
test_files: []
|
data/.gitignore
DELETED
data/.rubocop.yml
DELETED
data/Gemfile
DELETED
data/Gemfile.lock
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
PATH
|
|
2
|
-
remote: .
|
|
3
|
-
specs:
|
|
4
|
-
featureflow (0.6.0)
|
|
5
|
-
excon (~> 0.57.0)
|
|
6
|
-
|
|
7
|
-
GEM
|
|
8
|
-
remote: https://rubygems.org/
|
|
9
|
-
specs:
|
|
10
|
-
builder (3.2.3)
|
|
11
|
-
cucumber (2.4.0)
|
|
12
|
-
builder (>= 2.1.2)
|
|
13
|
-
cucumber-core (~> 1.5.0)
|
|
14
|
-
cucumber-wire (~> 0.0.1)
|
|
15
|
-
diff-lcs (>= 1.1.3)
|
|
16
|
-
gherkin (~> 4.0)
|
|
17
|
-
multi_json (>= 1.7.5, < 2.0)
|
|
18
|
-
multi_test (>= 0.1.2)
|
|
19
|
-
cucumber-core (1.5.0)
|
|
20
|
-
gherkin (~> 4.0)
|
|
21
|
-
cucumber-wire (0.0.1)
|
|
22
|
-
diff-lcs (1.3)
|
|
23
|
-
excon (0.57.1)
|
|
24
|
-
gherkin (4.1.3)
|
|
25
|
-
multi_json (1.12.1)
|
|
26
|
-
multi_test (0.1.2)
|
|
27
|
-
rake (10.5.0)
|
|
28
|
-
rspec (3.6.0)
|
|
29
|
-
rspec-core (~> 3.6.0)
|
|
30
|
-
rspec-expectations (~> 3.6.0)
|
|
31
|
-
rspec-mocks (~> 3.6.0)
|
|
32
|
-
rspec-core (3.6.0)
|
|
33
|
-
rspec-support (~> 3.6.0)
|
|
34
|
-
rspec-expectations (3.6.0)
|
|
35
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
|
36
|
-
rspec-support (~> 3.6.0)
|
|
37
|
-
rspec-mocks (3.6.0)
|
|
38
|
-
diff-lcs (>= 1.2.0, < 2.0)
|
|
39
|
-
rspec-support (~> 3.6.0)
|
|
40
|
-
rspec-support (3.6.0)
|
|
41
|
-
|
|
42
|
-
PLATFORMS
|
|
43
|
-
ruby
|
|
44
|
-
|
|
45
|
-
DEPENDENCIES
|
|
46
|
-
bundler (~> 1.15)
|
|
47
|
-
cucumber (~> 2.4.0)
|
|
48
|
-
featureflow!
|
|
49
|
-
rake (~> 10.0)
|
|
50
|
-
rspec (~> 3.0)
|
|
51
|
-
|
|
52
|
-
BUNDLED WITH
|
|
53
|
-
1.15.3
|
data/Rakefile
DELETED
data/featureflow.gemspec
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# coding: utf-8
|
|
2
|
-
lib = File.expand_path('../lib', __FILE__)
|
|
3
|
-
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
-
require 'featureflow/version'
|
|
5
|
-
|
|
6
|
-
Gem::Specification.new do |spec|
|
|
7
|
-
spec.name = "featureflow"
|
|
8
|
-
spec.version = Featureflow::VERSION
|
|
9
|
-
spec.authors = ["Oliver Oldfield-Hodge"]
|
|
10
|
-
spec.email = ["contact@featureflow.io"]
|
|
11
|
-
|
|
12
|
-
spec.summary = "Ruby SDK for Featureflow"
|
|
13
|
-
spec.description = "Connects your server with Featureflow. Create features at https://featureflow.io."
|
|
14
|
-
spec.homepage = "https://github.com/featureflow/featureflow-ruby-sdk"
|
|
15
|
-
spec.license = "MIT"
|
|
16
|
-
|
|
17
|
-
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
|
18
|
-
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
|
19
|
-
# if spec.respond_to?(:metadata)
|
|
20
|
-
# spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
|
|
21
|
-
# else
|
|
22
|
-
# raise "RubyGems 2.0 or newer is required to protect against " \
|
|
23
|
-
# "public gem pushes."
|
|
24
|
-
# end
|
|
25
|
-
|
|
26
|
-
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
-
f.match(%r{^(test|spec|features)/})
|
|
28
|
-
end
|
|
29
|
-
spec.bindir = "exe"
|
|
30
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
31
|
-
spec.require_paths = ["lib"]
|
|
32
|
-
|
|
33
|
-
spec.add_development_dependency "bundler", "~> 1.15"
|
|
34
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
|
35
|
-
spec.add_development_dependency "rspec", "~> 3.0"
|
|
36
|
-
spec.add_development_dependency "cucumber", "~> 2.4.0"
|
|
37
|
-
spec.add_dependency "excon", "~> 0.57.0"
|
|
38
|
-
end
|
data/publish-gem.sh
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
gem push
|
data/test.rb
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
$:.unshift(File.dirname(__FILE__) + "/lib/")
|
|
2
|
-
|
|
3
|
-
require 'featureflow'
|
|
4
|
-
with_features = [
|
|
5
|
-
Featureflow::Feature.create('default', 'variant'),
|
|
6
|
-
Featureflow::Feature.create('oli-f1', 'off')
|
|
7
|
-
]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
api_key = 'srv-env-'
|
|
12
|
-
config = Featureflow::Configuration.new
|
|
13
|
-
config.api_key = api_key
|
|
14
|
-
#config.endpoint = 'http://localhost:8081'
|
|
15
|
-
#config.event_endpoint = 'http://localhost:8081'
|
|
16
|
-
config.with_features = with_features
|
|
17
|
-
|
|
18
|
-
featureflow_client = Featureflow::Client.new(config)
|
|
19
|
-
=begin
|
|
20
|
-
featureflow_client = Featureflow::Client.new(api_key: api_key, with_features: with_features, url: 'http://localhost:8081')
|
|
21
|
-
=end
|
|
22
|
-
user = Featureflow::UserBuilder.new('user1').build
|
|
23
|
-
puts('test-integration is on? ' + featureflow_client.evaluate('test-integration', user).on?.to_s)
|
|
24
|
-
featureflow_client.evaluate('nooooo', user).on?
|
|
25
|
-
featureflow_client.evaluate('default', user).on?
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
loop do
|
|
29
|
-
sleep 10
|
|
30
|
-
puts(featureflow_client.evaluate('oli-f1', user).value)
|
|
31
|
-
puts(featureflow_client.evaluate('oli-f1', user).is?('extended'))
|
|
32
|
-
puts(featureflow_client.evaluate('default', user).on?)
|
|
33
|
-
end
|