ldclient-rb 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 9e44a1aa86a1b8486920b0798f25160481a8fd06
4
- data.tar.gz: 0ecdeb68b3479534fc148dc277181fd4d98d94c3
3
+ metadata.gz: abbd939adce22e127e3b1dc732d5d24487457a79
4
+ data.tar.gz: a903a1c807ce38b74c207ef53196198d5d16f489
5
5
  SHA512:
6
- metadata.gz: 47fa2c0003a580ce85eb90a235a1405723fd891bef78bfff21cfa9cc84933b1f0d24dc228ccda1dd1a97d973426754bc246e2b0d80f26f0272b2e0c2d589f127
7
- data.tar.gz: e4dbca380db1a33754d595b98a17969981876c0d04ea36d275c8aec95f0de9d2abe519a3ac1d443964f4c399ad7e7bc3e6eb63f167123f9de347603820d5d3c4
6
+ metadata.gz: 85400dc792fbe9f4ddb982cc195b0342ac74fb2e97203a910242b4b72376d1fa28522a5468c6ee9861167a4fe6e06ab4aeeac131e3f043973d6fbe2130aa7754
7
+ data.tar.gz: 8c1a5de8549b1b26aa4aae92b716a06740e9bdd615c4a9bdc06d763800076e1a579068fc899fd1662ee600d1635f789eb73e5930214d8af1e8a57dfd5f78cbca
data/.gitignore CHANGED
@@ -11,4 +11,5 @@
11
11
  *.o
12
12
  *.a
13
13
  mkmf.log
14
- *.gem
14
+ *.gem
15
+ Gemfile.lock
data/Gemfile CHANGED
@@ -1,4 +1,3 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- # Specify your gem's dependencies in ldclient-rb.gemspec
4
3
  gemspec
data/README.md CHANGED
@@ -32,4 +32,9 @@ Your first feature flag
32
32
  Learn more
33
33
  -----------
34
34
 
35
- Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](http://docs.launchdarkly.com/v1.0/docs/ruby-sdk-reference).
35
+ Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](http://docs.launchdarkly.com/v1.0/docs/ruby-sdk-reference).
36
+
37
+ Contributing
38
+ ------------
39
+
40
+ We encourage pull-requests and other contributions from the community. We've also published an [SDK contributor's guide](http://docs.launchdarkly.com/v1.0/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work.
@@ -6,11 +6,11 @@ require 'ldclient-rb/version'
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "ldclient-rb"
8
8
  spec.version = LaunchDarkly::VERSION
9
- spec.authors = ["Catamorphic Co"]
10
- spec.email = ["team@catamorphic.com"]
9
+ spec.authors = ["LaunchDarkly"]
10
+ spec.email = ["team@launchdarkly.com"]
11
11
  spec.summary = %q{LaunchDarkly SDK for Ruby}
12
12
  spec.description = %q{Official LaunchDarkly SDK for Ruby}
13
- spec.homepage = ""
13
+ spec.homepage = "https://rubygems.org/gems/ldclient-rb"
14
14
  spec.license = "Apache 2.0"
15
15
 
16
16
  spec.files = `git ls-files -z`.split("\x0")
@@ -25,5 +25,8 @@ Gem::Specification.new do |spec|
25
25
  spec.add_runtime_dependency "faraday", "~> 0.9"
26
26
  spec.add_runtime_dependency "faraday-http-cache", "~> 0.4"
27
27
  spec.add_runtime_dependency "thread_safe", "~> 0.3"
28
- spec.add_runtime_dependency "net-http-persistent", "~> 2.9.4"
28
+ spec.add_runtime_dependency "net-http-persistent", "~> 2.9"
29
+ spec.add_runtime_dependency "concurrent-ruby", "~> 0.9"
30
+ spec.add_runtime_dependency "hashdiff", "~> 0.2"
31
+ spec.add_runtime_dependency "ld-em-eventsource", "~> 0.2"
29
32
  end
@@ -2,4 +2,5 @@ require "ldclient-rb/version"
2
2
  require "ldclient-rb/ldclient"
3
3
  require "ldclient-rb/store"
4
4
  require "ldclient-rb/config"
5
- require "ldclient-rb/newrelic"
5
+ require "ldclient-rb/newrelic"
6
+ require "ldclient-rb/stream"
@@ -23,6 +23,7 @@ module LaunchDarkly
23
23
  # @return [type] [description]
24
24
  def initialize(opts = {})
25
25
  @base_uri = (opts[:base_uri] || Config.default_base_uri).chomp("/")
26
+ @stream_uri = (opts[:stream_uri] || Config.default_stream_uri).chomp("/")
26
27
  @capacity = opts[:capacity] || Config.default_capacity
27
28
  @logger = opts[:logger] || Config.default_logger
28
29
  @store = opts[:store] || Config.default_store
@@ -30,6 +31,9 @@ module LaunchDarkly
30
31
  @connect_timeout = opts[:connect_timeout] || Config.default_connect_timeout
31
32
  @read_timeout = opts[:read_timeout] || Config.default_read_timeout
32
33
  @log_timings = opts[:log_timings] || Config.default_log_timings
34
+ @stream = opts[:stream] || Config.default_stream
35
+ @feature_store = opts[:feature_store] || Config.default_feature_store
36
+ @debug_stream = opts[:debug_stream] || Config.default_debug_stream
33
37
  end
34
38
 
35
39
  #
@@ -40,6 +44,32 @@ module LaunchDarkly
40
44
  @base_uri
41
45
  end
42
46
 
47
+ #
48
+ # The base URL for the LaunchDarkly streaming server.
49
+ #
50
+ # @return [String] The configured base URL for the LaunchDarkly streaming server.
51
+ def stream_uri
52
+ @stream_uri
53
+ end
54
+
55
+ #
56
+ # Whether streaming mode should be enabled. Streaming mode asynchronously updates
57
+ # feature flags in real-time using server-sent events.
58
+ #
59
+ # @return [Boolean] True if streaming mode should be enabled
60
+ def stream?
61
+ @stream
62
+ end
63
+
64
+ #
65
+ # Whether we should debug streaming mode. If set, the client will fetch features via polling
66
+ # and compare the retrieved feature with the value in the feature store
67
+ #
68
+ # @return [Boolean] True if we should debug streaming mode
69
+ def debug_stream?
70
+ @debug_stream
71
+ end
72
+
43
73
  #
44
74
  # The number of seconds between flushes of the event buffer. Decreasing the flush interval means
45
75
  # that the event buffer is less likely to reach capacity.
@@ -100,6 +130,13 @@ module LaunchDarkly
100
130
  @log_timings
101
131
  end
102
132
 
133
+ #
134
+ # TODO docs
135
+ #
136
+ def feature_store
137
+ @feature_store
138
+ end
139
+
103
140
  #
104
141
  # The default LaunchDarkly client configuration. This configuration sets reasonable defaults for most users.
105
142
  #
@@ -116,6 +153,10 @@ module LaunchDarkly
116
153
  "https://app.launchdarkly.com"
117
154
  end
118
155
 
156
+ def self.default_stream_uri
157
+ "https://stream.launchdarkly.com"
158
+ end
159
+
119
160
  def self.default_store
120
161
  defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : ThreadSafeMemoryStore.new
121
162
  end
@@ -140,5 +181,17 @@ module LaunchDarkly
140
181
  false
141
182
  end
142
183
 
184
+ def self.default_stream
185
+ false
186
+ end
187
+
188
+ def self.default_feature_store
189
+ nil
190
+ end
191
+
192
+ def self.default_debug_stream
193
+ false
194
+ end
195
+
143
196
  end
144
197
  end
@@ -5,6 +5,7 @@ require 'thread'
5
5
  require 'logger'
6
6
  require 'net/http/persistent'
7
7
  require 'benchmark'
8
+ require 'hashdiff'
8
9
 
9
10
  module LaunchDarkly
10
11
 
@@ -38,6 +39,10 @@ module LaunchDarkly
38
39
  end
39
40
  @offline = false
40
41
 
42
+ if @config.stream?
43
+ @stream_processor = StreamProcessor.new(api_key, config)
44
+ end
45
+
41
46
  @worker = create_worker()
42
47
  end
43
48
 
@@ -51,7 +56,6 @@ module LaunchDarkly
51
56
  rescue
52
57
  end
53
58
 
54
-
55
59
  if !events.empty?()
56
60
  res = log_timings("Flush events") {
57
61
  next @client.post (@config.base_uri + "/api/events/bulk") do |req|
@@ -124,12 +128,35 @@ module LaunchDarkly
124
128
  return default
125
129
  end
126
130
 
127
- value = get_flag_int(key, user, default)
131
+ unless user
132
+ @config.logger.error("[LDClient] Must specify user")
133
+ return default
134
+ end
135
+
136
+ if @config.stream? and not @stream_processor.started?
137
+ @stream_processor.start
138
+ end
139
+
140
+ if @config.stream? and @stream_processor.initialized?
141
+ feature = get_flag_stream(key)
142
+ if @config.debug_stream?
143
+ polled = get_flag_int(key)
144
+ diff = HashDiff.diff(feature, polled)
145
+ if not diff.empty?
146
+ @config.logger.error("Streamed flag differs from polled flag " + diff.to_s)
147
+ end
148
+ end
149
+ else
150
+ feature = get_flag_int(key)
151
+ end
152
+ value = evaluate(feature, user)
153
+ value.nil? ? default : value
154
+
128
155
  add_event({:kind => 'feature', :key => key, :user => user, :value => value})
129
156
  LDNewRelic.annotate_transaction(key, value)
130
157
  return value
131
158
  rescue StandardError => error
132
- @config.logger.error("[LDClient] Unhandled exception in get_flag: (#{error.class.name}) #{error.to_s}\n\t#{error.backtrace.join("\n\t")}")
159
+ @config.logger.error("[LDClient] Unhandled exception in toggle: (#{error.class.name}) #{error.to_s}\n\t#{error.backtrace.join("\n\t")}")
133
160
  default
134
161
  end
135
162
  end
@@ -142,7 +169,7 @@ module LaunchDarkly
142
169
  event[:creationDate] = (Time.now.to_f * 1000).to_i
143
170
  @queue.push(event)
144
171
 
145
- if ! @worker.alive?
172
+ if !@worker.alive?
146
173
  @worker = create_worker()
147
174
  end
148
175
  else
@@ -175,7 +202,7 @@ module LaunchDarkly
175
202
  # Tracks that a user performed an event
176
203
  #
177
204
  # @param event_name [String] The name of the event
178
- # @param user [Hash] The user that performed the event. This should be the same user hash used in calls to {#get_flag?}
205
+ # @param user [Hash] The user that performed the event. This should be the same user hash used in calls to {#toggle?}
179
206
  # @param data [Hash] A hash containing any additional data associated with the event
180
207
  #
181
208
  # @return [void]
@@ -183,14 +210,37 @@ module LaunchDarkly
183
210
  add_event({:kind => 'custom', :key => event_name, :user => user, :data => data })
184
211
  end
185
212
 
186
- def get_flag_int(key, user, default)
213
+ #
214
+ # Returns the key of every feature
215
+ #
216
+ def feature_keys
217
+ get_features.map {|feature| feature[:key]}
218
+ end
187
219
 
188
- unless user
189
- @config.logger.error("[LDClient] Must specify user")
190
- return default
220
+ #
221
+ # Returns all features
222
+ #
223
+ def get_features
224
+ res = @client.get (@config.base_uri + '/api/features') do |req|
225
+ req.headers['Authorization'] = 'api_key ' + @api_key
226
+ req.headers['User-Agent'] = 'RubyClient/' + LaunchDarkly::VERSION
227
+ req.options.timeout = @config.read_timeout
228
+ req.options.open_timeout = @config.connect_timeout
191
229
  end
192
230
 
193
- res = log_timings("Flush events") {
231
+ if res.status == 200 then
232
+ return JSON.parse(res.body, symbolize_names: true)[:items]
233
+ else
234
+ @config.logger.error("[LDClient] Unexpected status code #{res.status}")
235
+ end
236
+ end
237
+
238
+ def get_flag_stream(key)
239
+ @stream_processor.get_feature(key)
240
+ end
241
+
242
+ def get_flag_int(key)
243
+ res = log_timings("Feature request") {
194
244
  next @client.get (@config.base_uri + '/api/eval/features/' + key) do |req|
195
245
  req.headers['Authorization'] = 'api_key ' + @api_key
196
246
  req.headers['User-Agent'] = 'RubyClient/' + LaunchDarkly::VERSION
@@ -201,25 +251,21 @@ module LaunchDarkly
201
251
 
202
252
  if res.status == 401
203
253
  @config.logger.error("[LDClient] Invalid API key")
204
- return default
254
+ return nil
205
255
  end
206
256
 
207
257
  if res.status == 404
208
258
  @config.logger.error("[LDClient] Unknown feature key: #{key}")
209
- return default
259
+ return nil
210
260
  end
211
261
 
212
262
  if res.status != 200
213
263
  @config.logger.error("[LDClient] Unexpected status code #{res.status}")
214
- return default
264
+ return nil
215
265
  end
216
266
 
217
267
 
218
- feature = JSON.parse(res.body, :symbolize_names => true)
219
-
220
- val = evaluate(feature, user)
221
-
222
- val == nil ? default : val
268
+ JSON.parse(res.body, :symbolize_names => true)
223
269
  end
224
270
 
225
271
  def param_for_user(feature, user)
@@ -289,13 +335,13 @@ module LaunchDarkly
289
335
  end
290
336
 
291
337
  def evaluate(feature, user)
292
- unless feature[:on]
338
+ if feature.nil? || !feature[:on]
293
339
  return nil
294
340
  end
295
341
 
296
342
  param = param_for_user(feature, user)
297
343
 
298
- if param == nil
344
+ if param.nil?
299
345
  return nil
300
346
  end
301
347
 
@@ -344,7 +390,7 @@ module LaunchDarkly
344
390
  return res
345
391
  end
346
392
 
347
- private :add_event, :get_flag_int, :param_for_user, :match_target?, :match_user?, :match_variation?, :evaluate, :create_worker, :log_timings
393
+ private :add_event, :get_flag_stream, :get_flag_int, :param_for_user, :match_target?, :match_user?, :match_variation?, :evaluate, :create_worker, :log_timings
348
394
 
349
395
  end
350
396
  end
@@ -0,0 +1,166 @@
1
+ require 'concurrent/atomics'
2
+ require 'json'
3
+ require 'ld-em-eventsource'
4
+
5
+ module LaunchDarkly
6
+
7
+ PUT = "put"
8
+ PATCH = "patch"
9
+ DELETE = "delete"
10
+
11
+ class InMemoryFeatureStore
12
+ def initialize()
13
+ @features = Hash.new
14
+ @lock = Concurrent::ReadWriteLock.new
15
+ @initialized = Concurrent::AtomicBoolean.new(false)
16
+ end
17
+
18
+ def get(key)
19
+ @lock.with_read_lock {
20
+ f = @features[key.to_sym]
21
+ (f.nil? || f[:deleted]) ? nil : f
22
+ }
23
+ end
24
+
25
+ def all()
26
+ @lock.with_read_lock {
27
+ @features.select {|k,f| not f[:deleted]}
28
+ }
29
+ end
30
+
31
+ def delete(key, version)
32
+ @lock.with_write_lock {
33
+ old = @features[key.to_sym]
34
+
35
+ if old != nil and old[:version] < version
36
+ old[:deleted] = true
37
+ old[:version] = version
38
+ @features[key.to_sym] = old
39
+ elsif old.nil?
40
+ @features[key.to_sym] = {:deleted => true, :version => version}
41
+ end
42
+ }
43
+ end
44
+
45
+ def init(fs)
46
+ @lock.with_write_lock {
47
+ @features.replace(fs)
48
+ @initialized.make_true
49
+ }
50
+ end
51
+
52
+ def upsert(key, feature)
53
+ @lock.with_write_lock {
54
+ old = @features[key.to_sym]
55
+
56
+ if old.nil? or old[:version] < feature[:version]
57
+ @features[key.to_sym] = feature
58
+ end
59
+ }
60
+ end
61
+
62
+ def initialized?()
63
+ @initialized.value
64
+ end
65
+ end
66
+
67
+ class StreamProcessor
68
+ def initialize(api_key, config)
69
+ @api_key = api_key
70
+ @config = config
71
+ @store = config.feature_store ? config.feature_store : InMemoryFeatureStore.new
72
+ @disconnected = Concurrent::AtomicReference.new(nil)
73
+ @started = Concurrent::AtomicBoolean.new(false)
74
+ end
75
+
76
+ def initialized?()
77
+ @store.initialized?
78
+ end
79
+
80
+ def started?()
81
+ @started.value
82
+ end
83
+
84
+ def get_feature(key)
85
+ if not initialized?
86
+ throw :uninitialized
87
+ end
88
+ @store.get(key)
89
+ end
90
+
91
+ def start_reactor()
92
+ if defined?(Thin)
93
+ @config.logger.debug("Running in a Thin environment-- not starting EventMachine")
94
+ elsif EM.reactor_running?
95
+ @config.logger.debug("EventMachine already running")
96
+ else
97
+ @config.logger.debug("Starting EventMachine")
98
+ Thread.new { EM.run {} }
99
+ Thread.pass until EM.reactor_running?
100
+ end
101
+ EM.reactor_running?
102
+ end
103
+
104
+ def start()
105
+ # Try to start the reactor. If it's not started, we shouldn't start
106
+ # the stream processor
107
+ if not start_reactor
108
+ return
109
+ end
110
+
111
+ # If someone else booted the stream processor connection, just return
112
+ if not @started.make_true
113
+ return
114
+ end
115
+
116
+ # If we're the first and only thread to set started, boot
117
+ # the stream processor connection
118
+ EM.defer do
119
+ source = EM::EventSource.new(@config.stream_uri + "/features",
120
+ {},
121
+ {'Accept' => 'text/event-stream',
122
+ 'Authorization' => 'api_key ' + @api_key,
123
+ 'User-Agent' => 'RubyClient/' + LaunchDarkly::VERSION})
124
+ source.on PUT do |message|
125
+ features = JSON.parse(message, :symbolize_names => true)
126
+ @store.init(features)
127
+ set_connected
128
+ end
129
+ source.on PATCH do |message|
130
+ json = JSON.parse(message, :symbolize_names => true)
131
+ @store.upsert(json[:path][1..-1], json[:data])
132
+ set_connected
133
+ end
134
+ source.on DELETE do |message|
135
+ json = JSON.parse(message, :symbolize_names => true)
136
+ @store.delete(json[:path][1..-1], json[:version])
137
+ set_connected
138
+ end
139
+ source.error do |error|
140
+ @config.logger.error("[LDClient] Error subscribing to stream API: #{error}")
141
+ set_disconnected
142
+ end
143
+ source.inactivity_timeout = 0
144
+ source.start
145
+ end
146
+ end
147
+
148
+ def set_disconnected()
149
+ @disconnected.set(Time.now)
150
+ end
151
+
152
+ def set_connected()
153
+ @disconnected.set(nil)
154
+ end
155
+
156
+ def should_fallback_update()
157
+ disc = @disconnected.get
158
+ disc != nil and disc < (Time.now - 120)
159
+ end
160
+
161
+ # TODO mark private methods
162
+ private :set_connected, :set_disconnected, :start_reactor
163
+
164
+ end
165
+
166
+ end
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ldclient-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - Catamorphic Co
7
+ - LaunchDarkly
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-05-01 00:00:00.000000000 Z
11
+ date: 2015-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -100,24 +100,65 @@ dependencies:
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: 2.9.4
103
+ version: '2.9'
104
104
  type: :runtime
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: 2.9.4
110
+ version: '2.9'
111
+ - !ruby/object:Gem::Dependency
112
+ name: concurrent-ruby
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.9'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.9'
125
+ - !ruby/object:Gem::Dependency
126
+ name: hashdiff
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.2'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.2'
139
+ - !ruby/object:Gem::Dependency
140
+ name: ld-em-eventsource
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.2'
146
+ type: :runtime
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.2'
111
153
  description: Official LaunchDarkly SDK for Ruby
112
154
  email:
113
- - team@catamorphic.com
155
+ - team@launchdarkly.com
114
156
  executables: []
115
157
  extensions: []
116
158
  extra_rdoc_files: []
117
159
  files:
118
160
  - ".gitignore"
119
161
  - Gemfile
120
- - Gemfile.lock
121
162
  - LICENSE.txt
122
163
  - README.md
123
164
  - Rakefile
@@ -127,8 +168,9 @@ files:
127
168
  - lib/ldclient-rb/ldclient.rb
128
169
  - lib/ldclient-rb/newrelic.rb
129
170
  - lib/ldclient-rb/store.rb
171
+ - lib/ldclient-rb/stream.rb
130
172
  - lib/ldclient-rb/version.rb
131
- homepage: ''
173
+ homepage: https://rubygems.org/gems/ldclient-rb
132
174
  licenses:
133
175
  - Apache 2.0
134
176
  metadata: {}
@@ -1,30 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- ldclient-rb (0.1.0)
5
- faraday (~> 0.9)
6
- faraday-http-cache (~> 0.4)
7
- json (~> 1.8)
8
- net-http-persistent (~> 2.9.4)
9
- thread_safe (~> 0.3)
10
-
11
- GEM
12
- remote: https://rubygems.org/
13
- specs:
14
- faraday (0.9.1)
15
- multipart-post (>= 1.2, < 3)
16
- faraday-http-cache (0.4.2)
17
- faraday (~> 0.8)
18
- json (1.8.2)
19
- multipart-post (2.0.0)
20
- net-http-persistent (2.9.4)
21
- rake (10.4.2)
22
- thread_safe (0.3.4)
23
-
24
- PLATFORMS
25
- ruby
26
-
27
- DEPENDENCIES
28
- bundler (~> 1.7)
29
- ldclient-rb!
30
- rake (~> 10.0)