fluidfeatures 0.4.4 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,7 +1,166 @@
1
1
  [![Build Status](https://secure.travis-ci.org/FluidFeatures/fluidfeatures-ruby.png)](http://travis-ci.org/FluidFeatures/fluidfeatures-ruby)
2
2
 
3
- fluidfeatures-ruby
4
- ===================
3
+ Ruby graceful feature rollout and simple A/B testing
4
+ ====================================================
5
5
 
6
- Ruby client for API of FluidFeatures.com
6
+ `gem fluidfeatures-ruby` is a pure Ruby client for the API of FluidFeatures.com, which provides an elegant way to wrap new code so you have real-time control over rolling out new features to your user-base.
7
+
8
+ Integration with Rails
9
+ ----------------------
10
+
11
+ If you are looking to use FluidFeatures with Rails then see https://github.com/FluidFeatures/fluidfeatures-rails
12
+
13
+ `gem fluidfeatures-rails` uses this `gem fluidfeatures-ruby` but integrates into Rails, which makes it quick and easy to use FluidFeatures with your Rails application.
14
+
15
+ Ruby specific usage (non-Rails usage)
16
+ =====================================
17
+
18
+ This gem can be used in any Ruby application. This means a Sinatra application, offline processing or sending emails. Anything that touches your user-base can have a customized experience for each user and can be a vehicle for A/B testing. For instance, you can test different email formats and see which one results in more conversions on your website. You could even integrate into a [Map-Reduce](http://www.bigfastblog.com/map-reduce-with-ruby-using-hadoop) job.
19
+
20
+ Installation
21
+ ------------
22
+
23
+ ```
24
+ gem install fluidfeatures-ruby
25
+ ```
26
+
27
+ "Application"
28
+ -------------
29
+
30
+ You create a FluidFeatures::App (application) object with the credentials for accessing the FluidFeatures API. These credentials can be found of the application page of your FluidFeatures dashboard.
31
+
32
+ Call `app` on the `FluidFeatures` module to instantiate this object.
33
+
34
+ ```ruby
35
+ require 'fluidfeatures'
36
+
37
+ config = {
38
+ "baseuri" => "https://www.fluidfeatures.com/service"
39
+ "appid" => "1vu33ki6emqe3"
40
+ "secret" = "sssseeecrrreeetttt"
41
+ }
42
+
43
+ fluid_app = FluidFeatures.app(config)
44
+ ```
45
+
46
+ It's also possible to pass a logger object, which will direct all logging to your logger.
47
+
48
+ ```ruby
49
+ fluid_app = FluidFeatures.app(config.update("logger" => logger))
50
+ ```
51
+
52
+ User Transactions
53
+ -----------------
54
+
55
+ Each interaction with a user is wrapped in a transaction. In fluidfeature-rails, a transaction wraps a single HTTP request.
56
+
57
+ Transactions guarantee that the feature set for a user will be consistent during the transaction. It is possible that you might reconfigure which users see which features during a transaction, so this ensures that each request is processed atomically regardless of any changes that are occurring outside of the transaction.
58
+
59
+ To create a new user transaction call `user_transaction` on the `FluidFeatures::App` object, which we assigned to `fluid_app` above.
60
+
61
+ ```ruby
62
+ fluid_user_transaction = fluid_app.user_transaction(user_id, url, display_name, is_anonymous, unique_attrs, cohort_attrs)
63
+ ```
64
+
65
+ An transaction takes a few parameters.
66
+
67
+ `user_id` is the unique id that you generally refer to this user by. This can be a numeric or a string. If this is `nil` then the user is labeled anonymous and a unique id is generated to track this user. You can retrieve this unique id by using `anonymous_user_id = fluid_user_transaction.user.unique_id`. In HTTP context you should set a HTTP cookie with this `anonymous_user_id`, so that this user is consistently treated as the same user and experiences the same experience for each visit.
68
+
69
+ `url` is a reference to where the transaction is taking place. This has more meaning in HTTP context.
70
+
71
+ `display_name` is a human readable name of the user. eg. "Fred Flintstone". FluidFeatures will use this in the web dashboard for display purposes and also for searching for this user.
72
+
73
+ `is_anonymous` is a boolean that indicates whether this is an anonymous user or not. If this is true then `user_id` can be `nil` if you are not creating and tracking your own user ids for anonymous users.
74
+
75
+ `unique_attrs` is a `Hash` of other attributes that are unique to this user. For instance, their Twitter handle. Provide these are key-value pairs, which can be displayed and searched in the FluidFeatures dashboard. You can freely pass any key-value pairs you wish. For example, `:nickname => "The Hungry Bear"` or `:twitter => "@philwhln"`.
76
+
77
+ `cohort_attrs` is similar to `unique_attrs` but are not unique to this user. This is a way that you can group users together when managing feature visibility. These can be anything. A common one is `:admin => true` or `:admin => false`. Other ones include `:coffee_drinker => true`, `:age_group => "25-33"` or `:month_joined => "2012-july"`.
78
+
79
+ Within the FluidFeatures service, we plan to automatically add other cohorts, such as `:is_early_adopter`, `:active_user`, `:stale_user` to help you target your new features and engage the right users with the right features. This is outside the scope of this gem.
80
+
81
+ Is this feature enabled for this user?
82
+ --------------------------------------
83
+
84
+ This is at the core of graceful feature rollout. When the answer is always "no" for a feature, then you are able to push that feature into production without anyone knowing. After that you can start enabling it for specific users and groups of users.
85
+
86
+ To find out if the feature is enabled for the current user, call `feature_enabled?` on your `FluidFeatures::UserTransaction` object, which we assigned to `fluid_transaction` above.
87
+
88
+ ```ruby
89
+ if fluid_user_transaction.feature_enabled? feature_name
90
+ # implement feature here
91
+ end
92
+ ```
93
+
94
+ In the above example it will use `"default"` for the version of the feature. If you wish to define your own version, you can pass `version_name` as shown below.
95
+
96
+ ```ruby
97
+ if fluid_user_transaction.feature_enabled? feature_name, version_name
98
+ # implement feature here
99
+ end
100
+ ```
101
+
102
+ Real-world examples might be...
103
+
104
+ ```ruby
105
+ if fluid_user_transaction.feature_enabled? "theme", "old-one"
106
+ # show the user the old theme
107
+ end
108
+ if fluid_user_transaction.feature_enabled? "theme", "new-one"
109
+ # show the user the new theme
110
+ end
111
+ ```
112
+
113
+ ```ruby
114
+ if fluid_user_transaction.feature_enabled? "email-sender", "postfix"
115
+ # send email directly using postfix
116
+ end
117
+ if fluid_user_transaction.feature_enabled? "email-sender", "sendgrid-smtp"
118
+ # send email using sendgrid's smtp interface
119
+ end
120
+ if fluid_user_transaction.feature_enabled? "email-sender", "sendgrid-api"
121
+ # send email using sendgrid's http api
122
+ end
123
+ ```
124
+
125
+ When FluidFeatures::UserTransaction gets a call to `feature_enabled?` it may not have seen this feature version before, so it will report this back to the FluidFeatures service as a new feature version. By default, new feature versions are disabled for all users until they are assigned to specific users, cohorts or percentage of users. This is not always ideal, since you may want to wrap an existing feature, such as the `["theme","old-one"]` above, in order to phase it out or test it against a newer version. It that case you can tell FluidFeatures explicitly what the default enabled state of this feature version should be, by passing `default_enabled` boolean as `true` or `false`. `true` will result is all users initially seeing this feature version.
126
+
127
+ ```ruby
128
+ if fluid_user_transaction.feature_enabled? feature_name, version_name, default_enabled
129
+ # implement feature here
130
+ end
131
+ ```
132
+
133
+ It's a goal!
134
+ ------------
135
+
136
+ The main motivation behind FluidFeatures is to make your site better, increase your conversion rate and ensure that the features you are developing are something that your customers want.
137
+
138
+ Rolling out new features is fun and doing it gracefully is important, but rolling out the right features is even more important. With "goals" you can keep track of how well each feature is doing and even run short A/B or multi-variant tests to validate your hypothesis about which version of a feature you should rollout.
139
+
140
+ As you start to rollout a newer version of a feature, you can watch how well your goals are performing for that version and for other versions. If early indications show that your newer version sucks and conversions are lagging, then you can rollback or pause rollout until you can understand the issue better.
141
+
142
+ Common high-level goals are "signed-up-to-mailing-list", "clicked-buy-button" or "visited-at-least-5-pages-during-session". Lower-level ones might include, "page-loaded-in-under-one-second", "no-errors-written-to-log" or "cpu-stayed-below-80-percent". Facebook tracks spikes in user comments, which they have found usually results in a feature gone wrong and users complaining. Your goals do not have to positive.
143
+
144
+ Goals are boolean and when one fires for a user then any feature enabled for that user at that time is held accountant, whether the goal is positive or negative.
145
+
146
+ Flagging a goal is easy. You simply call `goal_hit` on the `FluidFeatures::UserTransaction` object, which in the example below is `fluid_user_transaction`.
147
+
148
+ ```ruby
149
+ fluid_user_transaction.goal_hit goal_name
150
+ ```
151
+
152
+ Might look something like this...
153
+
154
+ ```ruby
155
+ def buy_button_clicked
156
+ @fluid_user_transaction.goal_hit "clicked-buy-button"
157
+ end
158
+ ```
159
+
160
+ This goal will automatically appear in your FluidFeatures dashboard after the first time `goal_hit` is called with this value.
161
+
162
+ More info
163
+ =========
164
+
165
+ For more information visit http://www.fluidfeatures.com or email support@fluidfeatures.com
7
166
 
data/lib/fluidfeatures.rb CHANGED
@@ -1,15 +1,24 @@
1
-
2
1
  require "logger"
3
2
 
3
+ require "fluidfeatures/config"
4
+
5
+ require "fluidfeatures/persistence/storage"
6
+ require "fluidfeatures/persistence/buckets"
7
+ require "fluidfeatures/persistence/features"
8
+
4
9
  require "fluidfeatures/client"
5
10
  require "fluidfeatures/app"
6
11
 
7
12
  module FluidFeatures
8
13
 
9
- def self.app(base_uri, app_id, secret, logger=nil)
10
- logger ||= ::Logger.new(STDERR)
11
- client = ::FluidFeatures::Client.new(base_uri, logger)
12
- ::FluidFeatures::App.new(client, app_id, secret, logger)
14
+ class << self
15
+ attr_accessor :config
13
16
  end
14
17
 
18
+ def self.app(config)
19
+ config["logger"] ||= ::Logger.new(STDERR)
20
+ self.config = config
21
+ client = ::FluidFeatures::Client.new(config["baseuri"], config["logger"])
22
+ ::FluidFeatures::App.new(client, config["appid"], config["secret"], config["logger"])
23
+ end
15
24
  end
@@ -6,7 +6,7 @@ module FluidFeatures
6
6
 
7
7
  attr_accessor :app
8
8
 
9
- # Throw away oldest buckets when this limit reached.
9
+ # Throw oldest buckets away or offload to persistent storage when this limit reached.
10
10
  MAX_BUCKETS = 10
11
11
 
12
12
  # Max number of transactions we queue in a bucket.
@@ -29,22 +29,39 @@ module FluidFeatures
29
29
  raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
30
30
  configure(app)
31
31
  run_loop
32
+ at_exit do
33
+ buckets_storage.append(@buckets)
34
+ end
35
+ end
36
+
37
+ def buckets_storage
38
+ @buckets_storage ||= FluidFeatures::Persistence::Buckets.create(FluidFeatures.config["cache"])
39
+ end
40
+
41
+ def features_storage
42
+ @features_storage ||= FluidFeatures::Persistence::Features.create(FluidFeatures.config["cache"])
32
43
  end
33
44
 
34
45
  def configure(app)
35
46
  @app = app
36
47
 
37
- @buckets = []
48
+ @buckets = buckets_storage.fetch(MAX_BUCKETS)
49
+
38
50
  @buckets_lock = ::Mutex.new
39
51
 
52
+ #maybe could get rid of @current_bucket concept
40
53
  @current_bucket = nil
41
54
  @current_bucket_lock = ::Mutex.new
42
- @current_bucket = new_bucket
55
+ @current_bucket = last_or_new_bucket
43
56
 
44
- @unknown_features = {}
57
+ @unknown_features = features_storage.list_unknown
45
58
  @unknown_features_lock = ::Mutex.new
46
59
  end
47
60
 
61
+ def last_or_new_bucket
62
+ @buckets.empty? || @buckets.last.size >= MAX_BUCKET_SIZE ? new_bucket : @buckets.last
63
+ end
64
+
48
65
  # Pass FluidFeatures::AppUserTransaction for reporting
49
66
  # back to the FluidFeatures service.
50
67
  def report_transaction(transaction)
@@ -76,6 +93,7 @@ module FluidFeatures
76
93
 
77
94
  if transaction.unknown_features.size > 0
78
95
  queue_unknown_features(transaction.unknown_features)
96
+ features_storage.replace_unknown(@unknown_features)
79
97
  end
80
98
 
81
99
  end
@@ -176,6 +194,8 @@ module FluidFeatures
176
194
  end
177
195
  # return unknown features to queue until the next attempt at sending
178
196
  queue_unknown_features(unknown_features)
197
+ else
198
+ features_storage.replace_unknown({})
179
199
  end
180
200
 
181
201
  # return whether we were able to send or not
@@ -194,16 +214,15 @@ module FluidFeatures
194
214
  @private
195
215
  def new_bucket
196
216
  bucket = []
197
- discarded_bucket = nil
198
217
  @buckets_lock.synchronize do
199
218
  @buckets << bucket
200
219
  if @buckets.size > MAX_BUCKETS
201
- discarded_bucket = @buckets.shift
220
+ #offload to storage
221
+ unless buckets_storage.append_one(@buckets.shift)
222
+ app.logger.warn "[FF] Discarded transactions due to reporter backlog. These will not be reported to FluidFeatures."
223
+ end
202
224
  end
203
225
  end
204
- if discarded_bucket
205
- app.logger.warn "[FF] Discarded #{discarded_bucket.size} transactions due to reporter backlog. These will not be reported to FluidFeatures."
206
- end
207
226
  bucket
208
227
  end
209
228
 
@@ -211,6 +230,11 @@ module FluidFeatures
211
230
  def remove_bucket
212
231
  removed_bucket = nil
213
232
  @buckets_lock.synchronize do
233
+ #try to get buckets from storage first
234
+ if @buckets.empty? && !buckets_storage.empty?
235
+ @buckets = buckets_storage.fetch(MAX_BUCKETS)
236
+ end
237
+
214
238
  if @buckets.size > 0
215
239
  removed_bucket = @buckets.shift
216
240
  end
@@ -231,6 +255,8 @@ module FluidFeatures
231
255
  if @buckets.size <= MAX_BUCKETS
232
256
  @buckets.unshift bucket
233
257
  success = true
258
+ else
259
+ success = buckets_storage.append_one(bucket)
234
260
  end
235
261
  end
236
262
  success
@@ -35,10 +35,14 @@ module FluidFeatures
35
35
 
36
36
  def configure(app)
37
37
  @app = app
38
- @features = {}
38
+ @features = features_storage.list
39
39
  @features_lock = ::Mutex.new
40
40
  end
41
41
 
42
+ def features_storage
43
+ @features_storage ||= FluidFeatures::Persistence::Features.create(FluidFeatures.config["cache"])
44
+ end
45
+
42
46
  def features
43
47
  f = nil
44
48
  @features_lock.synchronize do
@@ -50,6 +54,7 @@ module FluidFeatures
50
54
  def features= f
51
55
  return unless f.is_a? Hash
52
56
  @features_lock.synchronize do
57
+ features_storage.replace(f) unless @features == f
53
58
  @features = f
54
59
  end
55
60
  end
@@ -12,7 +12,7 @@ module FluidFeatures
12
12
  @url = url
13
13
 
14
14
  # take a snap-shot of the features end at
15
- # the beginning of the transactionapplication
15
+ # the beginning of the transaction
16
16
  @features = user.features
17
17
 
18
18
  @features_hit = {}
@@ -23,7 +23,7 @@ module FluidFeatures
23
23
 
24
24
  end
25
25
 
26
- def feature_enabled?(feature_name, version_name, default_enabled)
26
+ def feature_enabled?(feature_name, version_name=nil, default_enabled=nil)
27
27
  raise "transaction ended" if ended
28
28
  raise "feature_name invalid : #{feature_name}" unless feature_name.is_a? String
29
29
  version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
@@ -73,7 +73,7 @@ module FluidFeatures
73
73
  end
74
74
  end
75
75
 
76
- def goal_hit(goal_name, goal_version_name)
76
+ def goal_hit(goal_name, goal_version_name=nil)
77
77
  raise "transaction ended" if ended
78
78
  raise "goal_name invalid : #{goal_name}" unless goal_name.is_a? String
79
79
  goal_version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
@@ -0,0 +1,24 @@
1
+
2
+ require 'yaml'
3
+ require 'erb'
4
+
5
+ module FluidFeatures
6
+ class Config
7
+ attr_accessor :vars
8
+ def initialize(path, environment, replacements = {})
9
+ self.vars = YAML.load(ERB.new(File.read(path)).result)
10
+ self.vars = vars["common"].update(vars[environment])
11
+ self.vars = vars.update(replacements)
12
+ self.vars["cache"]["limit"] = self.class.parse_file_size(vars["cache"]["limit"]) if vars["cache"]
13
+ end
14
+
15
+ def [](name)
16
+ @vars[name.to_s]
17
+ end
18
+
19
+ def self.parse_file_size(size)
20
+ return size if !size || size.is_a?(Numeric) || !/(\d+)\s*(kb|mb|gb)$/.match(size.downcase)
21
+ $1.to_i * 1024 ** (%w{kb mb gb}.index($2) + 1)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ module FluidFeatures
2
+ module Persistence
3
+ class Buckets < Storage
4
+ attr_accessor :limit
5
+
6
+ def self.create(config)
7
+ return NullBuckets.new unless config && config["dir"] && config["enable"] && config["limit"] > 0
8
+ new(config)
9
+ end
10
+
11
+ def initialize(config)
12
+ self.limit = config["limit"]
13
+ super config
14
+ end
15
+
16
+ def fetch(n = 1)
17
+ ret = []
18
+ store.transaction do
19
+ return [] unless store && store["buckets"] && !store["buckets"].empty?
20
+ n = store["buckets"].size if n > store["buckets"].size
21
+ ret = store["buckets"].slice!(0, n)
22
+ store.commit
23
+ end
24
+ return ret
25
+ end
26
+
27
+ def append(buckets)
28
+ transaction do
29
+ store["buckets"] += buckets
30
+ end
31
+ end
32
+
33
+ def append_one(bucket)
34
+ transaction do
35
+ store["buckets"].push(bucket)
36
+ end
37
+ end
38
+
39
+ def empty?
40
+ store.transaction(true) do
41
+ return true unless store["buckets"]
42
+ return store["buckets"].empty?
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def transaction
49
+ begin
50
+ return false if file_size > limit
51
+ store.transaction do
52
+ store["buckets"] ||= []
53
+ yield
54
+ end
55
+ rescue Exception => _
56
+ return false
57
+ end
58
+ end
59
+ end
60
+
61
+ class NullBuckets
62
+ def fetch(*args); [] end
63
+
64
+ def append(*args); false end
65
+
66
+ def append_one(*args); false end
67
+
68
+ def empty?; true end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,62 @@
1
+ module FluidFeatures
2
+ module Persistence
3
+ class Features < Storage
4
+
5
+ def self.create(config)
6
+ return NullFeatures.new unless config && config["dir"] && config["enable"]
7
+ new(config)
8
+ end
9
+
10
+ def initialize(config)
11
+ super config
12
+ end
13
+
14
+ def list
15
+ store.transaction(true) do
16
+ return {} unless store && store["features"]
17
+ store["features"]
18
+ end
19
+ end
20
+
21
+ def replace(features)
22
+ transaction do
23
+ store["features"] = features
24
+ !!store["features"]
25
+ end
26
+ end
27
+
28
+ def list_unknown
29
+ store.transaction(true) do
30
+ return {} unless store && store["unknown_features"]
31
+ store["unknown_features"]
32
+ end
33
+ end
34
+
35
+ def replace_unknown(features)
36
+ transaction do
37
+ store["unknown_features"] = features
38
+ !!store["unknown_features"]
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def transaction
45
+ begin
46
+ store.transaction do
47
+ yield
48
+ end
49
+ rescue Exception => _
50
+ return false
51
+ end
52
+ end
53
+ end
54
+
55
+ class NullFeatures
56
+ def list; {} end
57
+ def list_unknown; {} end
58
+ def replace(*args); false end
59
+ def replace_unknown(*args); false end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ require "pstore"
2
+
3
+ module FluidFeatures
4
+ module Persistence
5
+ class Storage
6
+ attr_accessor :dir, :file_name, :store, :logger
7
+
8
+ def initialize(config)
9
+ self.dir = config["dir"]
10
+ self.logger = config["logger"] || Logger.new(STDERR)
11
+ self.file_name = "#{self.class.to_s.split('::').last.downcase}.pstore"
12
+ FileUtils.mkpath(dir) unless Dir.exists?(dir)
13
+ end
14
+
15
+ def store
16
+ @store ||= PStore.new(path, true)
17
+ end
18
+
19
+ def path
20
+ File.join(dir, file_name)
21
+ end
22
+
23
+ def file_size
24
+ begin
25
+ File.size(path)
26
+ rescue Exception => _
27
+ return 0
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,3 +1,3 @@
1
1
  module FluidFeatures
2
- VERSION = '0.4.4'
2
+ VERSION = '0.5.0'
3
3
  end
@@ -16,10 +16,102 @@ describe FluidFeatures::AppReporter do
16
16
 
17
17
  before(:each) do
18
18
  described_class.any_instance.stub(:run_loop)
19
+ FluidFeatures.stub(:config).and_return(config)
19
20
  end
20
21
 
21
- describe "#report_transaction" do
22
+ describe "#features_storage" do
23
+ before(:each) do
24
+ described_class.any_instance.stub(:configure)
25
+ FluidFeatures::Persistence::Features.should_receive(:create).with(config["cache"]).twice
26
+ .and_return(FluidFeatures::Persistence::NullFeatures.new)
27
+ end
28
+
29
+ it "should create features storage" do
30
+ reporter.features_storage
31
+ end
32
+
33
+ it "should not call create twice" do
34
+ reporter.features_storage
35
+ reporter.features_storage
36
+ end
37
+ end
38
+
39
+ describe "#buckets_storage" do
40
+ before(:each) do
41
+ described_class.any_instance.stub(:configure)
42
+ FluidFeatures::Persistence::Buckets.should_receive(:create).with(config["cache"])
43
+ .and_return(FluidFeatures::Persistence::NullBuckets.new)
44
+ end
45
+
46
+ it "should create buckets storage" do
47
+ reporter.buckets_storage
48
+ end
49
+
50
+ it "should not call create twice" do
51
+ reporter.buckets_storage
52
+ reporter.buckets_storage
53
+ end
54
+ end
55
+
56
+ describe "#configure" do
57
+ let(:features_storage) { mock("features_storage") }
58
+ let(:buckets_storage) { mock("buckets_storage") }
59
+ let(:bucket) { mock("bucket") }
60
+
61
+ before(:each) do
62
+ reporter.stub!(:features_storage).and_return(features_storage)
63
+ reporter.stub!(:buckets_storage).and_return(buckets_storage)
64
+ reporter.stub!(:last_or_new_bucket)
65
+ features_storage.stub!(:list_unknown)
66
+ buckets_storage.stub!(:fetch)
67
+ end
68
+
69
+ it "should assign result of buckets_storage.fetch to @buckets" do
70
+ buckets = mock("buckets")
71
+ buckets_storage.should_receive(:fetch).with(described_class::MAX_BUCKETS).and_return(buckets)
72
+ reporter.configure(app)
73
+ reporter.instance_variable_get(:@buckets).should == buckets
74
+ end
75
+
76
+ it "should assign result of last_or_new_bucket to @current_bucket" do
77
+ reporter.should_receive(:last_or_new_bucket).and_return(bucket)
78
+ reporter.configure(app)
79
+ reporter.instance_variable_get(:@current_bucket).should == bucket
80
+ end
81
+
82
+ it "should assign result of features_storage.list_unknown to @unknown_features" do
83
+ features = mock("features")
84
+ features_storage.should_receive(:list_unknown).and_return(features)
85
+ reporter.configure(app)
86
+ reporter.instance_variable_get(:@unknown_features).should == features
87
+ end
88
+ end
89
+
90
+ describe "last_or_new_bucket" do
91
+ it "should return new bucket if buckets empty" do
92
+ reporter.instance_variable_set(:@buckets, [])
93
+ reporter.should_receive(:new_bucket).and_return([])
94
+ reporter.last_or_new_bucket.should == []
95
+ end
96
+
97
+ it "should return new bucket if last bucket is full" do
98
+ bucket = mock("bucket")
99
+ bucket.should_receive(:size).and_return(described_class::MAX_BUCKET_SIZE + 1)
100
+ reporter.instance_variable_set(:@buckets, [bucket])
101
+ reporter.should_receive(:new_bucket).and_return([])
102
+ reporter.last_or_new_bucket.should == []
103
+ end
104
+
105
+ it "should return last bucket of buckets" do
106
+ bucket = mock("bucket")
107
+ bucket.should_receive(:size).and_return(described_class::MAX_BUCKET_SIZE - 1)
108
+ reporter.instance_variable_set(:@buckets, [bucket])
109
+ reporter.should_not_receive(:new_bucket)
110
+ reporter.last_or_new_bucket.should == bucket
111
+ end
112
+ end
22
113
 
114
+ describe "#report_transaction" do
23
115
  before(:each) do
24
116
  reporter.stub!(:queue_transaction_payload)
25
117
  reporter.stub!(:queue_unknown_features)
@@ -41,7 +133,6 @@ describe FluidFeatures::AppReporter do
41
133
  reporter.should_receive(:queue_unknown_features).with(transaction.unknown_features)
42
134
  reporter.report_transaction(transaction)
43
135
  end
44
-
45
136
  end
46
137
 
47
138
  describe "#transactions_queued?" do
@@ -78,6 +169,7 @@ describe FluidFeatures::AppReporter do
78
169
 
79
170
  describe "#queue_unknown_features" do
80
171
  let(:unknown_features) { { "feature" => { "a" => true } } }
172
+
81
173
  it "should add unknown_features to instance variable" do
82
174
  reporter.queue_unknown_features(unknown_features)
83
175
  reporter.instance_variable_get(:@unknown_features).should == unknown_features
@@ -90,11 +182,11 @@ describe FluidFeatures::AppReporter do
90
182
  it "should raise error if versions is not Hash" do
91
183
  expect { reporter.queue_unknown_features("feature" => "not cool") }.to raise_error /should be a Hash/
92
184
  end
93
-
94
185
  end
95
186
 
96
187
  describe "#send_transactions" do
97
188
  let(:unknown_features) { { "feature" => { "a" => true } } }
189
+
98
190
  let(:app) { mock "FluidFeatures::App", client: mock("client", uuid: 'client uuid'), logger: mock('logger') }
99
191
 
100
192
  before(:each) do
@@ -120,8 +212,82 @@ describe FluidFeatures::AppReporter do
120
212
  reporter.send_transactions
121
213
  end
122
214
 
215
+ it "should reset unknown features in storage on success" do
216
+ app.stub!(:post).and_return(true)
217
+ reporter.features_storage.should_receive(:replace_unknown).with({})
218
+ reporter.send_transactions
219
+ end
123
220
  end
124
221
 
125
- end
222
+ describe "#new_bucket" do
223
+ let(:buckets) { [] }
224
+
225
+ before(:each) do
226
+ reporter.instance_variable_set(:@buckets, buckets)
227
+ end
228
+
229
+ it "should append first bucket to storage if over the limit" do
230
+ buckets.stub!(:size).and_return(described_class::MAX_BUCKETS + 1)
231
+ reporter.buckets_storage.should_receive(:append_one).with([])
232
+ reporter.new_bucket
233
+ end
234
+
235
+ it "should append first bucket to storage if" do
236
+ buckets.stub!(:size).and_return(described_class::MAX_BUCKETS - 1)
237
+ reporter.buckets_storage.should_not_receive(:append)
238
+ reporter.new_bucket
239
+ end
240
+ end
241
+
242
+ describe "#remove_bucket" do
243
+ let(:buckets) { [] }
244
+
245
+ before(:each) do
246
+ reporter.instance_variable_set(:@buckets, buckets)
247
+ end
248
+
249
+ it "should load buckets from storage if buckets empty and something in storage" do
250
+ buckets.stub!(:empty?).and_return(true)
251
+ reporter.buckets_storage.stub!(:empty?).and_return(false)
252
+ reporter.buckets_storage.should_receive(:fetch).and_return(buckets)
253
+ reporter.remove_bucket
254
+ end
255
+
256
+ it "should not load buckets if buckets not empty" do
257
+ buckets.stub!(:empty?).and_return(false)
258
+ reporter.buckets_storage.stub!(:empty?).and_return(false)
259
+ reporter.buckets_storage.should_not_receive(:fetch)
260
+ reporter.remove_bucket
261
+ end
262
+
263
+ it "should not load buckets if storage empty" do
264
+ buckets.stub!(:empty?).and_return(true)
265
+ reporter.buckets_storage.stub!(:empty?).and_return(true)
266
+ reporter.buckets_storage.should_not_receive(:fetch)
267
+ reporter.remove_bucket
268
+ end
269
+ end
270
+
271
+ describe "#unremove_bucket" do
272
+ let(:buckets) { [] }
126
273
 
274
+ let(:bucket) { mock("bucket") }
275
+
276
+ before(:each) do
277
+ reporter.instance_variable_set(:@buckets, buckets)
278
+ end
279
+
280
+ it "should offload bucket to storage if buckets over limit" do
281
+ buckets.stub!(:size).and_return(described_class::MAX_BUCKETS + 1)
282
+ reporter.buckets_storage.should_receive(:append_one).with(bucket)
283
+ reporter.unremove_bucket(bucket)
284
+ end
285
+
286
+ it "should not offload bucket to storage if buckets under limit" do
287
+ buckets.stub!(:size).and_return(described_class::MAX_BUCKETS - 1)
288
+ reporter.buckets_storage.should_not_receive(:append)
289
+ reporter.unremove_bucket(bucket)
290
+ end
291
+ end
292
+ end
127
293
  end
@@ -1,4 +1,6 @@
1
1
  require 'spec_helper'
2
+ require "fluidfeatures/persistence/buckets"
3
+ require "fluidfeatures/persistence/features"
2
4
 
3
5
  describe FluidFeatures::AppState do
4
6
 
@@ -15,10 +17,54 @@ describe FluidFeatures::AppState do
15
17
  app.stub!(:is_a?).and_return(false)
16
18
  app.stub!(:is_a?).with(FluidFeatures::App).and_return(true)
17
19
  described_class.any_instance.stub(:run_loop)
20
+ FluidFeatures.stub(:config).and_return(config)
18
21
  end
19
22
 
20
- describe "#load_state" do
23
+ describe "#features_storage" do
24
+ before(:each) do
25
+ described_class.any_instance.stub(:configure)
26
+ FluidFeatures::Persistence::Features.should_receive(:create).with(config["cache"])
27
+ .and_return(FluidFeatures::Persistence::NullFeatures.new)
28
+ end
29
+
30
+ it "should create features storage" do
31
+ state.features_storage
32
+ end
33
+
34
+ it "should not call create twice" do
35
+ state.features_storage
36
+ state.features_storage
37
+ end
38
+ end
39
+
40
+ describe "#configure" do
41
+ let(:features_storage) { mock("features_storage") }
21
42
 
43
+ before(:each) do
44
+ state.stub!(:features_storage).and_return(features_storage)
45
+ end
46
+
47
+ it "should assign result of features_storage.list to @features" do
48
+ features = mock("features")
49
+ features_storage.should_receive(:list).and_return(features)
50
+ state.configure(app)
51
+ state.instance_variable_get(:@features).should == features
52
+ end
53
+ end
54
+
55
+ describe "#features=" do
56
+ it "should replace features if there are changes" do
57
+ state.features_storage.should_receive(:replace).with({foo: "bar"})
58
+ state.features = {foo: "bar"}
59
+ end
60
+
61
+ it "should not replace features if not amended" do
62
+ state.features_storage.should_not_receive(:replace)
63
+ state.features = {}
64
+ end
65
+ end
66
+
67
+ describe "#load_state" do
22
68
  before(:each) do
23
69
  app.stub!(:get)
24
70
  end
@@ -34,7 +80,6 @@ describe FluidFeatures::AppState do
34
80
  }])
35
81
  state.load_state.should == [true, "feature" => { "versions" => { "a" => { "parts" => Set.new([1, 2, 3]) } } }]
36
82
  end
37
-
38
83
  end
39
84
 
40
85
  describe "#feature_version_enabled_for_user" do
@@ -67,9 +112,6 @@ describe FluidFeatures::AppState do
67
112
  it "should return true if stated implicitly" do
68
113
  state.feature_version_enabled_for_user("Feature", "a", 5, { "key" => "id" }).should be_true
69
114
  end
70
-
71
115
  end
72
-
73
116
  end
74
-
75
117
  end
@@ -0,0 +1,53 @@
1
+ require "spec_helper"
2
+
3
+ describe FluidFeatures::Config do
4
+ let(:path) { "#{File.dirname(__FILE__)}/fixtures/fluidfeatures.yml" }
5
+
6
+ let(:env) { "development" }
7
+
8
+ let(:replacements) { {} }
9
+
10
+ let(:config) { FluidFeatures::Config.new(path, env, replacements) }
11
+
12
+ context "parse file size" do
13
+ [nil, 1024, "foo"].each do |input|
14
+ it "should pass #{input.class.to_s} through" do
15
+ described_class.parse_file_size(input).should == input
16
+ end
17
+ end
18
+
19
+ { "1Kb" => 1024, "2mb" => 2097152, "2GB" => 2147483648}.each do |i, o|
20
+ it "should read '#{i}' as #{o}" do
21
+ described_class.parse_file_size(i).should == o
22
+ end
23
+ end
24
+ end
25
+
26
+ %w{test development production}.each do |env_name|
27
+ context "with #{env_name} environment" do
28
+ let(:env) { env_name }
29
+
30
+ it "should load environment configuration from yml file and merge common section" do
31
+ config.vars.should == {
32
+ "baseuri" => "baseuri",
33
+ "cache" => { "enable" => false, "dir" => "cache_dir", "limit" => 2097152 },
34
+ "appid" => "#{env_name}_appid",
35
+ "secret" => "#{env_name}_secret"
36
+ }
37
+ end
38
+
39
+ context "and replacements" do
40
+ let(:replacements) { { "baseuri" => "env_baseuri", "appid" => "env_appid", "secret" => "env_secret" } }
41
+
42
+ it "should update variables with passed hash" do
43
+ config.vars.should == {
44
+ "baseuri" => "env_baseuri",
45
+ "cache" => { "enable" => false, "dir" => "cache_dir", "limit" => 2097152 },
46
+ "appid" => "env_appid",
47
+ "secret" => "env_secret"
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,18 @@
1
+ common:
2
+ baseuri: baseuri
3
+ cache:
4
+ enable: false
5
+ dir: cache_dir
6
+ limit: 2mb
7
+
8
+ development:
9
+ appid: development_appid
10
+ secret: development_secret
11
+
12
+ test:
13
+ appid: test_appid
14
+ secret: test_secret
15
+
16
+ production:
17
+ appid: production_appid
18
+ secret: production_secret
@@ -3,26 +3,30 @@ require "spec_helper"
3
3
  describe "FluidFeatures" do
4
4
 
5
5
  describe "App initialization" do
6
-
7
6
  it "should raise 'host not set' without valid base uri" do
8
- expect { FluidFeatures.app(nil, api_credentials[1], api_credentials[2]) }.
7
+ expect { FluidFeatures.app(config.remove("baseuri")) }.
9
8
  to raise_error(StandardError, /host not set/)
10
9
  end
11
10
 
12
11
  it "should raise 'app_id invalid' without valid app id" do
13
- expect { FluidFeatures.app(api_credentials[0], nil, api_credentials[2]) }.
12
+ expect { FluidFeatures.app(config.remove("appid")) }.
14
13
  to raise_error(StandardError, /app_id invalid/)
15
14
  end
16
15
 
17
16
  it "should raise 'secret invalid' without valid secret" do
18
- expect { FluidFeatures.app(api_credentials[0], api_credentials[1], nil) }.
17
+ expect { FluidFeatures.app(config.remove("secret")) }.
19
18
  to raise_error(StandardError, /secret invalid/)
20
19
  end
21
20
 
21
+ it "should set @config class variable to passed config" do
22
+ FluidFeatures::Client.stub!(:new); FluidFeatures::App.stub!(:new)
23
+ config = mock("config", "[]" => nil, "[]=" => nil)
24
+ FluidFeatures.app(config)
25
+ FluidFeatures.config.should == config
26
+ end
22
27
  end
23
28
 
24
29
  describe "API methods" do
25
-
26
30
  #let(:feature_name) { "Feature-#{UUID.new.generate}" }
27
31
  let(:feature_name) { "Feature1" }
28
32
 
@@ -47,7 +51,5 @@ describe "FluidFeatures" do
47
51
  sleep abit
48
52
  end
49
53
  end
50
-
51
54
  end
52
-
53
55
  end
@@ -0,0 +1,95 @@
1
+ require "spec_helper"
2
+
3
+ describe FluidFeatures::Persistence::Buckets do
4
+ context "instance" do
5
+ let(:storage) { described_class.create(config["cache"]) }
6
+
7
+ describe "#fetch" do
8
+ it "should return [] for empty storage" do
9
+ storage.fetch.should == []
10
+ end
11
+
12
+ it "should return [] for empty storage when called with limit" do
13
+ storage.fetch(10).should == []
14
+ end
15
+
16
+ it "should return limited set when called with limit" do
17
+ storage.append([["bucket0"], ["bucket1"], ["bucket2"]])
18
+ storage.fetch(2).should == [["bucket0"], ["bucket1"]]
19
+ storage.should_not be_empty
20
+ storage.fetch.should == [["bucket2"]]
21
+ storage.should be_empty
22
+
23
+ end
24
+
25
+ it "should return limited set when called with limit over of bounds" do
26
+ storage.append([["bucket0"], ["bucket1"], ["bucket2"]])
27
+ storage.fetch(10).should == [["bucket0"], ["bucket1"], ["bucket2"]]
28
+ storage.should be_empty
29
+ end
30
+ end
31
+
32
+ describe "#append" do
33
+ it "should append buckets to empty storage" do
34
+ storage.append([["bucket"]]).should be_true
35
+ storage.fetch(10).should == [["bucket"]]
36
+ end
37
+
38
+ it "should append buckets to existing buckets" do
39
+ storage.append_one(["bucket0"])
40
+ storage.append([["bucket1"], ["bucket2"]]).should be_true
41
+ storage.fetch(10).should == [["bucket0"], ["bucket1"], ["bucket2"]]
42
+ end
43
+
44
+ it "should not append and return false if storage size over the limit" do
45
+ storage.stub!(:file_size).and_return(2)
46
+ storage.stub!(:limit).and_return(1)
47
+ storage.append([["bucket0"], ["bucket1"]]).should be_false
48
+ storage.fetch.should == []
49
+ end
50
+ end
51
+
52
+ describe "#append_one" do
53
+ it "should append bucket to empty storage" do
54
+ storage.append_one(["bucket"]).should be_true
55
+ storage.fetch.should == [["bucket"]]
56
+ end
57
+
58
+ it "should append bucket to existing buckets" do
59
+ storage.append([["bucket0"], ["bucket1"]])
60
+ storage.append_one(["bucket2"]).should be_true
61
+ storage.fetch(10).should == [["bucket0"], ["bucket1"], ["bucket2"]]
62
+ end
63
+
64
+ it "should not append and return false if storage size over the limit" do
65
+ storage.stub!(:file_size).and_return(2)
66
+ storage.stub!(:limit).and_return(1)
67
+ storage.append_one(["bucket"]).should be_false
68
+ storage.fetch.should == []
69
+ end
70
+ end
71
+
72
+ describe "#empty?" do
73
+ it "should return true for fresh storage" do
74
+ storage.should be_empty
75
+ end
76
+
77
+ it "should return false when storage has buckets" do
78
+ storage.append_one(["bucket"])
79
+ storage.should_not be_empty
80
+ end
81
+ end
82
+ end
83
+
84
+ context "null instance" do
85
+ let(:storage) { described_class.create(nil) }
86
+
87
+ specify { storage.fetch.should == [] }
88
+
89
+ specify { storage.append([["bucket0"], ["bucket1"]]).should == false }
90
+
91
+ specify { storage.append_one(["bucket"]).should == false }
92
+
93
+ specify { storage.empty?.should == true }
94
+ end
95
+ end
@@ -0,0 +1,37 @@
1
+ require "spec_helper"
2
+
3
+ describe FluidFeatures::Persistence::Features do
4
+ context "instance" do
5
+ let(:storage) { described_class.create(config["cache"]) }
6
+
7
+ it "#list should return all features" do
8
+ storage.replace({ feature: "foo" })
9
+ storage.list.should == ({ feature: "foo" })
10
+ end
11
+
12
+ it "#replace should replace features with passed hash" do
13
+ storage.replace({ feature: "foo" }).should == true
14
+ end
15
+
16
+ it "#list_unknown should return unknown_features features" do
17
+ storage.replace_unknown({ feature: "foo" })
18
+ storage.list_unknown.should == ({ feature: "foo" })
19
+ end
20
+
21
+ it "#replace_unknown should replace unknown_features with passed hash" do
22
+ storage.replace_unknown({ feature: "foo" }).should == true
23
+ end
24
+ end
25
+
26
+ context "null instance" do
27
+ let(:storage) { described_class.create(nil) }
28
+
29
+ specify { storage.list.should == {} }
30
+
31
+ specify { storage.list_unknown.should == {} }
32
+
33
+ specify { storage.replace([["bucket0"], ["bucket1"]]).should == false }
34
+
35
+ specify { storage.replace_unknown([["bucket0"], ["bucket1"]]).should == false }
36
+ end
37
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,9 +1,8 @@
1
- # This file was generated by the `rspec --init` command. Conventionally, all
2
- # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
- # Require this file using `require "spec_helper"` to ensure that it is only
4
- # loaded once.
5
- #
6
- # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
1
+
2
+ unless ENV["FLUIDFEATURES_APPID"]
3
+ $stderr.puts "Expect ENV variabled FLUIDFEATURES_APPID FLUIDFEATURES_SECRET FLUIDFEATURES_BASEURI to run the tests"
4
+ exit!
5
+ end
7
6
 
8
7
  require 'vcr'
9
8
  require 'fluidfeatures'
@@ -17,6 +16,7 @@ VCR.configure do |c|
17
16
  end
18
17
 
19
18
  RSpec.configure do |config|
19
+ config.fail_fast = true
20
20
  config.treat_symbols_as_metadata_keys_with_true_values = true
21
21
  config.run_all_when_everything_filtered = true
22
22
  config.filter_run :focus
@@ -25,4 +25,9 @@ RSpec.configure do |config|
25
25
 
26
26
  config.extend VCR::RSpec::Macros
27
27
  config.include FluidFeatures::ApiHelpers
28
+
29
+ config.before(:each) do
30
+ dir = File.join(File.dirname(__FILE__), "tmp")
31
+ FileUtils.rm_rf(dir)
32
+ end
28
33
  end
@@ -1,11 +1,17 @@
1
1
  module FluidFeatures
2
2
  module ApiHelpers
3
- def api_credentials
4
- [ ENV["FLUIDFEATURES_BASEURI"], ENV["FLUIDFEATURES_APPID"], ENV["FLUIDFEATURES_SECRET"] ]
3
+ def config
4
+ {
5
+ "cache" => { "enable" => true, "dir" => "spec/tmp", "limit" => 1024 ** 2 },
6
+ "baseuri" => ENV["FLUIDFEATURES_BASEURI"],
7
+ "appid" => ENV["FLUIDFEATURES_APPID"],
8
+ "secret" => ENV["FLUIDFEATURES_SECRET"],
9
+ "logger" => Logger.new("/dev/null")
10
+ }
5
11
  end
6
12
 
7
13
  def app
8
- @app ||= FluidFeatures.app(*api_credentials.push( Logger.new("/dev/null") ))
14
+ @app ||= FluidFeatures.app(config)
9
15
  end
10
16
 
11
17
  def transaction
@@ -16,7 +22,6 @@ module FluidFeatures
16
22
  def commit(transaction)
17
23
  transaction.end_transaction
18
24
  @transaction = transaction = nil
19
- #Thread.list.map &:join
20
25
  end
21
26
 
22
27
  #time to sleep waiting for thread
@@ -0,0 +1,11 @@
1
+ class Hash
2
+ def remove!(*keys)
3
+ keys.each{|key| self.delete(key) }
4
+ self
5
+ end
6
+
7
+ def remove(*keys)
8
+ self.dup.remove!(*keys)
9
+ end
10
+ end
11
+
@@ -1,14 +1,13 @@
1
1
  shared_examples "polling loop" do |payload_method|
2
-
3
2
  let(:app) { mock "FluidFeatures::App" }
4
3
 
5
4
  before(:each) do
6
5
  app.stub!(:is_a?).and_return(false)
7
6
  app.stub!(:is_a?).with(FluidFeatures::App).and_return(true)
7
+ FluidFeatures.stub(:config).and_return(config)
8
8
  end
9
9
 
10
10
  context "initialization" do
11
-
12
11
  before(:each) do
13
12
  described_class.any_instance.stub(:configure)
14
13
  described_class.any_instance.stub(:run_loop)
@@ -38,5 +37,4 @@ shared_examples "polling loop" do |payload_method|
38
37
  it "should initialize @app" do
39
38
  described_class.new(app).app.should == app
40
39
  end
41
-
42
40
  end
@@ -0,0 +1,12 @@
1
+ class String
2
+ def const
3
+ names = self.split('::')
4
+ names.shift if names.empty? || names.first.empty?
5
+
6
+ constant = Object
7
+ names.each do |name|
8
+ constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
9
+ end
10
+ constant
11
+ end
12
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fluidfeatures
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.4
4
+ version: 0.5.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-12-05 00:00:00.000000000 Z
12
+ date: 2013-01-08 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: persistent_http
@@ -163,7 +163,11 @@ files:
163
163
  - lib/fluidfeatures/app/transaction.rb
164
164
  - lib/fluidfeatures/app/user.rb
165
165
  - lib/fluidfeatures/client.rb
166
+ - lib/fluidfeatures/config.rb
166
167
  - lib/fluidfeatures/const.rb
168
+ - lib/fluidfeatures/persistence/buckets.rb
169
+ - lib/fluidfeatures/persistence/features.rb
170
+ - lib/fluidfeatures/persistence/storage.rb
167
171
  - lib/fluidfeatures/version.rb
168
172
  - lib/pre_ruby192/uri.rb
169
173
  - spec/app/feature_spec.rb
@@ -174,10 +178,16 @@ files:
174
178
  - spec/app_spec.rb
175
179
  - spec/cassettes/feature.yml
176
180
  - spec/cassettes/goal.yml
177
- - spec/fluidfeatures_spec.rb
181
+ - spec/config_spec.rb
182
+ - spec/fixtures/fluidfeatures.yml
183
+ - spec/integration/fluidfeatures_spec.rb
184
+ - spec/persistence/buckets_spec.rb
185
+ - spec/persistence/features_spec.rb
178
186
  - spec/spec_helper.rb
179
187
  - spec/support/api_helpers.rb
188
+ - spec/support/hash_ext.rb
180
189
  - spec/support/polling_loop_shared_examples.rb
190
+ - spec/support/string_ext.rb
181
191
  homepage: https://github.com/FluidFeatures/fluidfeatures-ruby
182
192
  licenses: []
183
193
  post_install_message: