etcd-rb 1.0.0.pre0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Ruby CQL3 driver
2
+
3
+ [![Build Status](https://travis-ci.org/iconara/etcd-rb.png?branch=master)](https://travis-ci.org/iconara/etcd-rb)
4
+ [![Coverage Status](https://coveralls.io/repos/iconara/etcd-rb/badge.png?branch=master)](https://coveralls.io/r/iconara/etcd-rb)
5
+
6
+ # Requirements
7
+
8
+ A modern Ruby, compatible with 1.9.3 or later. Continously tested with MRI 1.9.3, 2.0.0 and JRuby 1.7.x.
9
+
10
+ # Installation
11
+
12
+ gem install etcd-rb --prerelease
13
+
14
+ # Quick start
15
+
16
+ ```ruby
17
+ require 'etcd'
18
+
19
+ client = Etcd::Client.new
20
+ client.set('/foo', 'bar')
21
+ client.get('/foo')
22
+ ```
23
+
24
+ See the full [API documentation](http://rubydoc.info/gems/etcd-rb/frames) for more.
25
+
26
+ # Changelog & versioning
27
+
28
+ Check out the [releases on GitHub](https://github.com/iconara/etcd-rb/releases). Version numbering follows the [semantic versioning](http://semver.org/) scheme.
29
+
30
+ # How to contribute
31
+
32
+ Fork the repository, make your changes in a topic branch that branches off from the right place in the history (HEAD isn't necessarily always right), make your changes and finally submit a pull request.
33
+
34
+ Follow the style of the existing code, make sure that existing tests pass, and that everything new has good test coverage. Put some effort into writing clear and concise commit messages, and write a good pull request description.
35
+
36
+ It takes time to understand other people's code, and even more time to understand a patch, so do as much as you can to make the maintainers' work easier. Be prepared for rejection, many times a feature is already planned, or the proposed design would be in the way of other planned features, or the maintainers' just feel that it will be faster to implement the features themselves than to try to integrate your patch.
37
+
38
+ Feel free to open a pull request before the feature is finished, that way you can have a conversation with the maintainers' during the development, and you can make adjustments to the design as you go along instead of having your whole feature rejected because of reasons such as those above. If you do, please make it clear that the pull request is a work in progress, or a request for comment.
39
+
40
+ Always remember that the maintainers' work on this project in their free time and that they don't work for you, or for your benefit. They have no obligation to do what you think is right -- but if you're nice they might anyway.
41
+
42
+ # Copyright
43
+
44
+ Copyright 2013 Theo Hultberg/Iconara
45
+
46
+ _Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License You may obtain a copy of the License at_
47
+
48
+ [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
49
+
50
+ _Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License._
data/lib/etcd.rb ADDED
@@ -0,0 +1,8 @@
1
+ # encoding: utf-8
2
+
3
+ module Etcd
4
+ EtcdError = Class.new(StandardError)
5
+ end
6
+
7
+ require 'etcd/version'
8
+ require 'etcd/client'
@@ -0,0 +1,346 @@
1
+ # encoding: utf-8
2
+
3
+ require 'time'
4
+ require 'thread'
5
+ require 'httpclient'
6
+ require 'multi_json'
7
+
8
+
9
+ module Etcd
10
+ # A client for `etcd`. Implements all core operations (`get`, `set`, `delete`
11
+ # and `watch`) and features (TTL, atomic test-and-set, listing directories,
12
+ # etc).
13
+ #
14
+ # In addition to the core operations there are a few convenience methods for
15
+ # doing test-and-set (a.k.a. compare-and-swap, atomic update), and continuous
16
+ # watching.
17
+ #
18
+ # @note All methods that take a key or prefix as argument will prepend a slash
19
+ # to the key if it does not start with slash.
20
+ #
21
+ # @example Basic usage
22
+ # client = Etcd::Client.new
23
+ # client.set('/foo/bar', 'baz')
24
+ # client.get('/foo/bar') # => 'baz'
25
+ # client.delete('/foo/bar') # => 'baz'
26
+ #
27
+ # @example Make a key expire automatically after 5s
28
+ # client.set('/foo', 'bar', ttl: 5)
29
+ #
30
+ # @example Atomic updates
31
+ # client.set('/foo/bar', 'baz')
32
+ # # ...
33
+ # if client.update('/foo/bar', 'qux', 'baz')
34
+ # puts 'Nobody changed our data'
35
+ # end
36
+ #
37
+ # @example Listing a directory
38
+ # client.set('/foo/bar', 'baz')
39
+ # client.set('/foo/qux', 'fizz')
40
+ # client.get('/foo') # => {'/foo/bar' => 'baz', '/foo/qux' => 'fizz'}
41
+ #
42
+ # @example Getting info for a key
43
+ # client.set('/foo', 'bar', ttl: 5)
44
+ # client.info('/foo') # => {:key => '/foo',
45
+ # # :value => '/bar',
46
+ # # :expires => Time.utc(...),
47
+ # # :ttl => 4}
48
+ #
49
+ # @example Observing changes to a key
50
+ # observer = client.observe('/foo') do |value, key|
51
+ # # This will be run asynchronously
52
+ # puts "The key #{key}" changed to #{value}"
53
+ # end
54
+ # client.set('/foo/bar', 'baz') # "The key /foo/bar changed to baz" is printed
55
+ # client.set('/foo/qux', 'fizz') # "The key /foo/qux changed to fizz" is printed
56
+ # # stop receiving change notifications
57
+ # observer.cancel
58
+ #
59
+ class Client
60
+ # Creates a new `etcd` client.
61
+ #
62
+ # @param [Hash] options
63
+ # @option options [String] :host ('127.0.0.1') The etcd host to connect to
64
+ # @option options [String] :port (4001) The port to connect to
65
+ def initialize(options={})
66
+ @host = options[:host] || '127.0.0.1'
67
+ @port = options[:port] || 4001
68
+ @http_client = HTTPClient.new(agent_name: "etcd-rb/#{VERSION}")
69
+ end
70
+
71
+ # Sets the value of a key.
72
+ #
73
+ # Accepts an optional `:ttl` which is the number of seconds that the key
74
+ # should live before being automatically deleted.
75
+ #
76
+ # @param key [String] the key to set
77
+ # @param value [String] the value to set
78
+ # @param options [Hash]
79
+ # @option options [Fixnum] :ttl (nil) an optional time to live (in seconds)
80
+ # for the key
81
+ # @return [String] The previous value (if any)
82
+ def set(key, value, options={})
83
+ body = {:value => value}
84
+ if ttl = options[:ttl]
85
+ body[:ttl] = ttl
86
+ end
87
+ response = @http_client.post(uri(key), body)
88
+ data = MultiJson.load(response.body)
89
+ data[S_PREV_VALUE]
90
+ end
91
+
92
+ # Atomically sets the value for a key if the current value for the key
93
+ # matches the specified expected value.
94
+ #
95
+ # Returns `true` when the operation succeeds, i.e. when the specified
96
+ # expected value matches the current value. Returns `false` otherwise.
97
+ #
98
+ # Accepts an optional `:ttl` which is the number of seconds that the key
99
+ # should live before being automatically deleted.
100
+ #
101
+ # @param key [String] the key to set
102
+ # @param value [String] the value to set
103
+ # @param expected_value [String] the value to compare to the current value
104
+ # @param options [Hash]
105
+ # @option options [Fixnum] :ttl (nil) an optional time to live (in seconds)
106
+ # for the key
107
+ # @return [true, false] whether or not the operation succeeded
108
+ def update(key, value, expected_value, options={})
109
+ body = {:value => value, :prevValue => expected_value}
110
+ if ttl = options[:ttl]
111
+ body[:ttl] = ttl
112
+ end
113
+ response = @http_client.post(uri(key), body)
114
+ response.status == 200
115
+ end
116
+
117
+ # Gets the value or values for a key.
118
+ #
119
+ # If the key represents a directory with direct decendants (e.g. "/foo" for
120
+ # "/foo/bar") a hash of keys and values will be returned.
121
+ #
122
+ # @param key [String] the key or prefix to retrieve
123
+ # @return [String, Hash] the value for the key, or a hash of keys and values
124
+ # when the key is a prefix.
125
+ def get(key)
126
+ response = @http_client.get(uri(key))
127
+ if response.status == 200
128
+ data = MultiJson.load(response.body)
129
+ if data.is_a?(Array)
130
+ data.each_with_object({}) do |e, acc|
131
+ acc[e[S_KEY]] = e[S_VALUE]
132
+ end
133
+ else
134
+ data[S_VALUE]
135
+ end
136
+ else
137
+ nil
138
+ end
139
+ end
140
+
141
+ # Returns info about a key, such as TTL, expiration and index.
142
+ #
143
+ # For keys with values the returned hash will include `:key`, `:value` and
144
+ # `:index`. Additionally for keys with a TTL set there will be a `:ttl` and
145
+ # `:expiration` (as a UTC `Time`).
146
+ #
147
+ # For keys that represent directories with no direct decendants (e.g. "/foo"
148
+ # for "/foo/bar/baz") the `:dir` key will have the value `true`.
149
+ #
150
+ # For keys that represent directories with direct decendants (e.g. "/foo"
151
+ # for "/foo/bar") a hash of keys and info will be returned.
152
+ #
153
+ # @param key [String] the key or prefix to retrieve
154
+ # @return [Hash] a with info about the key, the exact contents depend on
155
+ # what kind of key it is.
156
+ def info(key)
157
+ response = @http_client.get(uri(key))
158
+ if response.status == 200
159
+ data = MultiJson.load(response.body)
160
+ if data.is_a?(Array)
161
+ data.each_with_object({}) do |d, acc|
162
+ info = extract_info(d)
163
+ info.delete(:action)
164
+ acc[info[:key]] = info
165
+ end
166
+ else
167
+ info = extract_info(data)
168
+ info.delete(:action)
169
+ info
170
+ end
171
+ else
172
+ nil
173
+ end
174
+ end
175
+
176
+ # Remove a key and its value.
177
+ #
178
+ # The previous value is returned, or `nil` if the key did not exist.
179
+ #
180
+ # @param key [String] the key to remove
181
+ # @return [String] the previous value, if any
182
+ def delete(key)
183
+ response = @http_client.delete(uri(key))
184
+ if response.status == 200
185
+ data = MultiJson.load(response.body)
186
+ data[S_PREV_VALUE]
187
+ else
188
+ nil
189
+ end
190
+ end
191
+
192
+ # Returns true if the specified key exists.
193
+ #
194
+ # This is a convenience method and equivalent to calling {#get} and checking
195
+ # if the value is `nil`.
196
+ #
197
+ # @return [true, false] whether or not the specified key exists
198
+ def exists?(key)
199
+ !!get(key)
200
+ end
201
+
202
+ # Watches a key or prefix and calls the given block when with any changes.
203
+ #
204
+ # This method will block until the server replies. There is no way to cancel
205
+ # the call.
206
+ #
207
+ # The parameters to the block are the value, the key and a hash of
208
+ # additional info. The info will contain the `:action` that caused the
209
+ # change (`:set`, `:delete` etc.), the `:key`, the `:value`, the `:index`,
210
+ # `:new_key` with the value `true` when a new key was created below the
211
+ # watched prefix, `:previous_value`, if any, `:ttl` and `:expiration` if
212
+ # applicable.
213
+ #
214
+ # The reason why the block parameters are in the order`value`, `key` instead
215
+ # of `key`, `value` is because you almost always want to get the new value
216
+ # when you watch, but not always the key, and most often not the info. With
217
+ # this order you can leave out the parameters you don't need.
218
+ #
219
+ # @param prefix [String] the key or prefix to watch
220
+ # @param options [Hash]
221
+ # @option options [Fixnum] :index (nil) the index to start watching from
222
+ # @yieldparam [String] value the value of the key that changed
223
+ # @yieldparam [String] key the key that changed
224
+ # @yieldparam [Hash] info the info for the key that changed
225
+ # @return [Object] the result of the given block
226
+ def watch(prefix, options={})
227
+ parameters = {}
228
+ if index = options[:index]
229
+ parameters[:index] = index
230
+ end
231
+ response = @http_client.get(uri(prefix, S_WATCH), parameters)
232
+ data = MultiJson.load(response.body)
233
+ info = extract_info(data)
234
+ yield info[:value], info[:key], info
235
+ end
236
+
237
+ # Sets up a continuous watch of a key or prefix.
238
+ #
239
+ # This method works like {#watch} (which is used behind the scenes), but
240
+ # will re-watch the key or prefix after receiving a change notificiation.
241
+ #
242
+ # When re-watching the index of the previous change notification is used,
243
+ # so no subsequent changes will be lost while a change is being processed.
244
+ #
245
+ # Unlike {#watch} this method as asynchronous. The watch handler runs in a
246
+ # separate thread (currently a new thread is created for each invocation,
247
+ # keep this in mind if you need to watch many different keys), and can be
248
+ # cancelled by calling `#cancel` on the returned object.
249
+ #
250
+ # Because of implementation details the watch handler thread will not be
251
+ # stopped directly when you call `#cancel`. The thread will be blocked until
252
+ # the next change notification (which will be ignored). This will have very
253
+ # little effect on performance since the thread will not be runnable. Unless
254
+ # you're creating lots of observers it should not matter. If you want to
255
+ # make sure you wait for the thread to stop you can call `#join` on the
256
+ # returned object.
257
+ #
258
+ # @example Creating and cancelling an observer
259
+ # observer = client.observe('/foo') do |value|
260
+ # # do something on changes
261
+ # end
262
+ # # ...
263
+ # observer.cancel
264
+ #
265
+ # @return [#cancel, #join] an observer object which you can call cancel and
266
+ # join on
267
+ def observe(prefix, &handler)
268
+ Observer.new(self, prefix, handler).tap(&:run)
269
+ end
270
+
271
+ private
272
+
273
+ S_KEY = 'key'.freeze
274
+ S_KEYS = 'keys'.freeze
275
+ S_VALUE = 'value'.freeze
276
+ S_INDEX = 'index'.freeze
277
+ S_EXPIRATION = 'expiration'.freeze
278
+ S_TTL = 'ttl'.freeze
279
+ S_NEW_KEY = 'newKey'.freeze
280
+ S_DIR = 'dir'.freeze
281
+ S_PREV_VALUE = 'prevValue'.freeze
282
+ S_ACTION = 'action'.freeze
283
+ S_WATCH = 'watch'.freeze
284
+
285
+ S_SLASH = '/'.freeze
286
+
287
+ def uri(key, action=S_KEYS)
288
+ key = "/#{key}" unless key.start_with?(S_SLASH)
289
+ "http://#{@host}:#{@port}/v1/#{action}#{key}"
290
+ end
291
+
292
+ def extract_info(data)
293
+ info = {
294
+ :key => data[S_KEY],
295
+ :value => data[S_VALUE],
296
+ :index => data[S_INDEX],
297
+ }
298
+ expiration_s = data[S_EXPIRATION]
299
+ ttl = data[S_TTL]
300
+ previous_value = data[S_PREV_VALUE]
301
+ action_s = data[S_ACTION]
302
+ info[:expiration] = Time.iso8601(expiration_s) if expiration_s
303
+ info[:ttl] = ttl if ttl
304
+ info[:new_key] = data[S_NEW_KEY] if data.include?(S_NEW_KEY)
305
+ info[:dir] = data[S_DIR] if data.include?(S_DIR)
306
+ info[:previous_value] = previous_value if previous_value
307
+ info[:action] = action_s.downcase.to_sym if action_s
308
+ info
309
+ end
310
+
311
+ # @private
312
+ class Observer
313
+ def initialize(client, prefix, handler)
314
+ @client = client
315
+ @prefix = prefix
316
+ @handler = handler
317
+ end
318
+
319
+ def run
320
+ @running = true
321
+ index = nil
322
+ @thread = Thread.start do
323
+ while @running
324
+ @client.watch(@prefix, index: index) do |value, key, info|
325
+ if @running
326
+ index = info[:index]
327
+ @handler.call(value, key, info)
328
+ end
329
+ end
330
+ end
331
+ end
332
+ self
333
+ end
334
+
335
+ def cancel
336
+ @running = false
337
+ self
338
+ end
339
+
340
+ def join
341
+ @thread.join
342
+ self
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,5 @@
1
+ # encoding: utf-8
2
+
3
+ module Etcd
4
+ VERSION = '1.0.0.pre0'.freeze
5
+ end
@@ -0,0 +1,345 @@
1
+ # encoding: utf-8
2
+
3
+ require 'spec_helper'
4
+
5
+
6
+ module Etcd
7
+ describe Client do
8
+ let :client do
9
+ described_class.new(host: host, port: port)
10
+ end
11
+
12
+ let :host do
13
+ 'example.com'
14
+ end
15
+
16
+ let :port do
17
+ rand(2**16)
18
+ end
19
+
20
+ let :base_uri do
21
+ "http://#{host}:#{port}/v1"
22
+ end
23
+
24
+ describe '#get' do
25
+ before do
26
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({'value' => 'bar'}))
27
+ end
28
+
29
+ it 'sends a GET request to retrieve the value for a key' do
30
+ client.get('/foo')
31
+ WebMock.should have_requested(:get, "#{base_uri}/keys/foo")
32
+ end
33
+
34
+ it 'prepends a slash to keys when necessary' do
35
+ client.get('foo')
36
+ WebMock.should have_requested(:get, "#{base_uri}/keys/foo")
37
+ end
38
+
39
+ it 'parses the response and returns the value' do
40
+ client.get('/foo').should == 'bar'
41
+ end
42
+
43
+ it 'returns nil if when the key does not exist' do
44
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(status: 404, body: 'Not found')
45
+ client.get('/foo').should be_nil
46
+ end
47
+
48
+ context 'when listing a prefix' do
49
+ it 'returns a hash of keys and their values' do
50
+ values = [
51
+ {'key' => '/foo/bar', 'value' => 'bar'},
52
+ {'key' => '/foo/baz', 'value' => 'baz'},
53
+ ]
54
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump(values))
55
+ client.get('/foo').should eql({'/foo/bar' => 'bar', '/foo/baz' => 'baz'})
56
+ end
57
+ end
58
+ end
59
+
60
+ describe '#set' do
61
+ before do
62
+ stub_request(:post, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({}))
63
+ end
64
+
65
+ it 'sends a POST request to set the value for a key' do
66
+ client.set('/foo', 'bar')
67
+ WebMock.should have_requested(:post, "#{base_uri}/keys/foo").with(body: 'value=bar')
68
+ end
69
+
70
+ it 'prepends a slash to keys when necessary' do
71
+ client.set('foo', 'bar')
72
+ WebMock.should have_requested(:post, "#{base_uri}/keys/foo").with(body: 'value=bar')
73
+ end
74
+
75
+ it 'parses the response and returns the previous value' do
76
+ stub_request(:post, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({'prevValue' => 'baz'}))
77
+ client.set('/foo', 'bar').should == 'baz'
78
+ end
79
+
80
+ it 'returns nil when there is no previous value' do
81
+ stub_request(:post, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({}))
82
+ client.set('/foo', 'bar').should be_nil
83
+ end
84
+
85
+ it 'sets a TTL when the :ttl option is given' do
86
+ client.set('/foo', 'bar', ttl: 3)
87
+ WebMock.should have_requested(:post, "#{base_uri}/keys/foo").with(body: 'value=bar&ttl=3')
88
+ end
89
+ end
90
+
91
+ describe '#update' do
92
+ before do
93
+ stub_request(:post, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({}))
94
+ end
95
+
96
+ it 'sends a POST request to set the value conditionally' do
97
+ client.update('/foo', 'bar', 'baz')
98
+ WebMock.should have_requested(:post, "#{base_uri}/keys/foo").with(body: 'value=bar&prevValue=baz')
99
+ end
100
+
101
+ it 'returns true when the key is successfully changed' do
102
+ stub_request(:post, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({}))
103
+ client.update('/foo', 'bar', 'baz').should be_true
104
+ end
105
+
106
+ it 'returns false when an error is returned' do
107
+ stub_request(:post, "#{base_uri}/keys/foo").to_return(status: 400, body: MultiJson.dump({}))
108
+ client.update('/foo', 'bar', 'baz').should be_false
109
+ end
110
+
111
+ it 'sets a TTL when the :ttl option is given' do
112
+ client.update('/foo', 'bar', 'baz', ttl: 3)
113
+ WebMock.should have_requested(:post, "#{base_uri}/keys/foo").with(body: 'value=bar&prevValue=baz&ttl=3')
114
+ end
115
+ end
116
+
117
+ describe '#delete' do
118
+ before do
119
+ stub_request(:delete, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({}))
120
+ end
121
+
122
+ it 'sends a DELETE request to remove a key' do
123
+ client.delete('/foo')
124
+ WebMock.should have_requested(:delete, "#{base_uri}/keys/foo")
125
+ end
126
+
127
+ it 'returns the previous value' do
128
+ stub_request(:delete, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({'prevValue' => 'bar'}))
129
+ client.delete('/foo').should == 'bar'
130
+ end
131
+
132
+ it 'returns nil when there is no previous value' do
133
+ stub_request(:delete, "#{base_uri}/keys/foo").to_return(status: 404, body: 'Not found')
134
+ client.delete('/foo').should be_nil
135
+ end
136
+ end
137
+
138
+ describe '#exists?' do
139
+ it 'returns true if the key has a value' do
140
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({'value' => 'bar'}))
141
+ client.exists?('/foo').should be_true
142
+ end
143
+
144
+ it 'returns false if the key does not exist' do
145
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(status: 404, body: 'Not found')
146
+ client.exists?('/foo').should be_false
147
+ end
148
+ end
149
+
150
+ describe '#info' do
151
+ it 'returns the key, value, index, expiration and TTL for a key' do
152
+ body = MultiJson.dump({'action' => 'GET', 'key' => '/foo', 'value' => 'bar', 'index' => 31, 'expiration' => '2013-12-11T12:09:08.123+02:00', 'ttl' => 7})
153
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: body)
154
+ info = client.info('/foo')
155
+ info[:key].should == '/foo'
156
+ info[:value].should == 'bar'
157
+ info[:index].should == 31
158
+ info[:expiration].to_f.should == (Time.utc(2013, 12, 11, 10, 9, 8) + 0.123).to_f
159
+ info[:ttl].should == 7
160
+ end
161
+
162
+ it 'returns the dir flag' do
163
+ body = MultiJson.dump({'action' => 'GET', 'key' => '/foo', 'dir' => true})
164
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: body)
165
+ info = client.info('/foo')
166
+ info[:key].should == '/foo'
167
+ info[:dir].should be_true
168
+ end
169
+
170
+ it 'returns only the pieces of information that are returned' do
171
+ body = MultiJson.dump({'action' => 'GET', 'key' => '/foo', 'value' => 'bar', 'index' => 31})
172
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: body)
173
+ info = client.info('/foo')
174
+ info[:key].should == '/foo'
175
+ info[:value].should == 'bar'
176
+ info[:index].should == 31
177
+ end
178
+
179
+ it 'returns nil when the key does not exist' do
180
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(status: 404, body: 'Not found')
181
+ client.info('/foo').should be_nil
182
+ end
183
+
184
+ context 'when listing a prefix' do
185
+ it 'returns a hash of keys and their info' do
186
+ body = MultiJson.dump([
187
+ {'action' => 'GET', 'key' => '/foo/bar', 'value' => 'bar', 'index' => 31},
188
+ {'action' => 'GET', 'key' => '/foo/baz', 'value' => 'baz', 'index' => 55},
189
+ ])
190
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: body)
191
+ info = client.info('/foo')
192
+ info['/foo/bar'][:key].should == '/foo/bar'
193
+ info['/foo/baz'][:key].should == '/foo/baz'
194
+ info['/foo/bar'][:value].should == 'bar'
195
+ info['/foo/baz'][:value].should == 'baz'
196
+ info['/foo/bar'][:index].should == 31
197
+ info['/foo/baz'][:index].should == 55
198
+ end
199
+ end
200
+ end
201
+
202
+ describe '#watch' do
203
+ it 'sends a GET request for a watch of a key prefix' do
204
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: MultiJson.dump({}))
205
+ client.watch('/foo') { }
206
+ WebMock.should have_requested(:get, "#{base_uri}/watch/foo").with(query: {})
207
+ end
208
+
209
+ it 'sends a GET request for a watch of a key prefix from a specified index' do
210
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {'index' => 3}).to_return(body: MultiJson.dump({}))
211
+ client.watch('/foo', index: 3) { }
212
+ WebMock.should have_requested(:get, "#{base_uri}/watch/foo").with(query: {'index' => 3})
213
+ end
214
+
215
+ it 'yields the value' do
216
+ body = MultiJson.dump({'value' => 'bar'})
217
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: body)
218
+ value = nil
219
+ client.watch('/foo') do |v|
220
+ value = v
221
+ end
222
+ value.should == 'bar'
223
+ end
224
+
225
+ it 'yields the changed key' do
226
+ body = MultiJson.dump({'key' => '/foo/bar', 'value' => 'bar'})
227
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: body)
228
+ key = nil
229
+ client.watch('/foo') do |_, k|
230
+ key = k
231
+ end
232
+ key.should == '/foo/bar'
233
+ end
234
+
235
+ it 'yields info about the key, when it is a new key' do
236
+ body = MultiJson.dump({'action' => 'SET', 'key' => '/foo/bar', 'value' => 'bar', 'index' => 3, 'newKey' => true})
237
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: body)
238
+ info = nil
239
+ client.watch('/foo') do |_, _, i|
240
+ info = i
241
+ end
242
+ info[:action].should == :set
243
+ info[:key].should == '/foo/bar'
244
+ info[:value].should == 'bar'
245
+ info[:index].should == 3
246
+ info[:new_key].should be_true
247
+ end
248
+
249
+ it 'yields info about the key, when the key was changed' do
250
+ body = MultiJson.dump({'action' => 'SET', 'key' => '/foo/bar', 'value' => 'bar', 'prevValue' => 'baz', 'index' => 3})
251
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: body)
252
+ info = nil
253
+ client.watch('/foo') do |_, _, i|
254
+ info = i
255
+ end
256
+ info[:action].should == :set
257
+ info[:key].should == '/foo/bar'
258
+ info[:value].should == 'bar'
259
+ info[:index].should == 3
260
+ info[:previous_value].should == 'baz'
261
+ end
262
+
263
+ it 'yields info about the key, when the key has a TTL' do
264
+ body = MultiJson.dump({'action' => 'SET', 'key' => '/foo/bar', 'value' => 'bar', 'index' => 3, 'expiration' => '2013-12-11T12:09:08.123+02:00', 'ttl' => 7})
265
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: body)
266
+ info = nil
267
+ client.watch('/foo') do |_, _, i|
268
+ info = i
269
+ end
270
+ info[:action].should == :set
271
+ info[:key].should == '/foo/bar'
272
+ info[:value].should == 'bar'
273
+ info[:index].should == 3
274
+ info[:expiration].to_f.should == (Time.utc(2013, 12, 11, 10, 9, 8) + 0.123).to_f
275
+ info[:ttl].should == 7
276
+ end
277
+
278
+ it 'returns the return value of the block' do
279
+ body = MultiJson.dump({'action' => 'SET', 'key' => '/foo/bar', 'value' => 'bar', 'index' => 3, 'expiration' => '2013-12-11T12:09:08.123+02:00', 'ttl' => 7})
280
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: body)
281
+ return_value = client.watch('/foo') do |_, k, _|
282
+ k
283
+ end
284
+ return_value.should == '/foo/bar'
285
+ end
286
+ end
287
+
288
+ describe '#observe' do
289
+ it 'watches the specified key prefix' do
290
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: MultiJson.dump({}))
291
+ barrier = Queue.new
292
+ observer = client.observe('/foo') do
293
+ barrier << :ping
294
+ observer.cancel
295
+ observer.join
296
+ end
297
+ barrier.pop
298
+ WebMock.should have_requested(:get, "#{base_uri}/watch/foo").with(query: {})
299
+ end
300
+
301
+ it 're-watches the prefix with the last seen index immediately' do
302
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: MultiJson.dump({'index' => 3}))
303
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {'index' => 3}).to_return(body: MultiJson.dump({'index' => 4}))
304
+ barrier = Queue.new
305
+ observer = client.observe('/foo') do |_, _, info|
306
+ if info[:index] == 4
307
+ barrier << :ping
308
+ observer.cancel
309
+ observer.join
310
+ end
311
+ end
312
+ barrier.pop
313
+ WebMock.should have_requested(:get, "#{base_uri}/watch/foo").with(query: {})
314
+ WebMock.should have_requested(:get, "#{base_uri}/watch/foo").with(query: {'index' => 3})
315
+ end
316
+
317
+ it 'yields the value, key and info to the block given' do
318
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {}).to_return(body: MultiJson.dump({'action' => 'SET', 'key' => '/foo/bar', 'value' => 'bar', 'index' => 3, 'newKey' => true}))
319
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {'index' => 3}).to_return(body: MultiJson.dump({'action' => 'DELETE', 'key' => '/foo/baz', 'value' => 'foo', 'index' => 4}))
320
+ stub_request(:get, "#{base_uri}/watch/foo").with(query: {'index' => 4}).to_return(body: MultiJson.dump({'action' => 'SET', 'key' => '/foo/bar', 'value' => 'hello', 'index' => 5}))
321
+ barrier = Queue.new
322
+ values = []
323
+ keys = []
324
+ actions = []
325
+ new_keys = []
326
+ observer = client.observe('/foo') do |value, key, info|
327
+ values << value
328
+ keys << key
329
+ actions << info[:action]
330
+ new_keys << info[:new_key]
331
+ if info[:index] == 5
332
+ barrier << :ping
333
+ observer.cancel
334
+ observer.join
335
+ end
336
+ end
337
+ barrier.pop
338
+ values.should == %w[bar foo hello]
339
+ keys.should == %w[/foo/bar /foo/baz /foo/bar]
340
+ actions.should == [:set, :delete, :set]
341
+ new_keys.should == [true, nil, nil]
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,5 @@
1
+ # encoding: utf-8
2
+
3
+ require 'bundler/setup'
4
+ require 'etcd'
5
+ require 'webmock/rspec'
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: etcd-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.pre0
5
+ prerelease: 6
6
+ platform: ruby
7
+ authors:
8
+ - Theo Hultberg
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-08-04 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: ''
15
+ email:
16
+ - theo@iconara.net
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - lib/etcd/client.rb
22
+ - lib/etcd/version.rb
23
+ - lib/etcd.rb
24
+ - README.md
25
+ - spec/etcd/client_spec.rb
26
+ - spec/spec_helper.rb
27
+ homepage: http://github.com/iconara/etcd-rb
28
+ licenses:
29
+ - Apache License 2.0
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ none: false
36
+ requirements:
37
+ - - ! '>='
38
+ - !ruby/object:Gem::Version
39
+ version: 1.9.3
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>'
44
+ - !ruby/object:Gem::Version
45
+ version: 1.3.1
46
+ requirements: []
47
+ rubyforge_project:
48
+ rubygems_version: 1.8.23
49
+ signing_key:
50
+ specification_version: 3
51
+ summary: ''
52
+ test_files:
53
+ - spec/etcd/client_spec.rb
54
+ - spec/spec_helper.rb
55
+ has_rdoc: