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 +50 -0
- data/lib/etcd.rb +8 -0
- data/lib/etcd/client.rb +346 -0
- data/lib/etcd/version.rb +5 -0
- data/spec/etcd/client_spec.rb +345 -0
- data/spec/spec_helper.rb +5 -0
- metadata +55 -0
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
data/lib/etcd/client.rb
ADDED
@@ -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
|
data/lib/etcd/version.rb
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
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:
|