blobby 1.0.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 +7 -0
- data/.gitignore +20 -0
- data/.rspec +1 -0
- data/.rubocop.yml +47 -0
- data/.travis.yml +5 -0
- data/Gemfile +10 -0
- data/README.md +43 -0
- data/Rakefile +13 -0
- data/blobby.gemspec +20 -0
- data/lib/blobby/composite_store.rb +64 -0
- data/lib/blobby/fake_success_store.rb +52 -0
- data/lib/blobby/filesystem_store.rb +133 -0
- data/lib/blobby/http_store.rb +135 -0
- data/lib/blobby/in_memory_store.rb +64 -0
- data/lib/blobby/key_constraint.rb +34 -0
- data/lib/blobby/key_transforming_store.rb +25 -0
- data/lib/blobby/logging_store.rb +67 -0
- data/lib/blobby/placeholder.png +0 -0
- data/lib/blobby/version.rb +3 -0
- data/spec/blobby/composite_store_spec.rb +54 -0
- data/spec/blobby/fake_success_store_spec.rb +134 -0
- data/spec/blobby/filesystem_store_spec.rb +206 -0
- data/spec/blobby/http_store_spec.rb +206 -0
- data/spec/blobby/in_memory_store_spec.rb +12 -0
- data/spec/blobby/key_transforming_store_spec.rb +102 -0
- data/spec/blobby/logging_store_spec.rb +45 -0
- data/spec/blobby/store_behaviour.rb +174 -0
- metadata +77 -0
@@ -0,0 +1,206 @@
|
|
1
|
+
require "ostruct"
|
2
|
+
require "blobby/http_store"
|
3
|
+
require "blobby/in_memory_store"
|
4
|
+
require "blobby/store_behaviour"
|
5
|
+
require "sinatra/base"
|
6
|
+
require "sham_rack"
|
7
|
+
|
8
|
+
describe Blobby::HttpStore do
|
9
|
+
|
10
|
+
let(:backing_store) do
|
11
|
+
Hash.new
|
12
|
+
end
|
13
|
+
|
14
|
+
class FakeStorageService < Sinatra::Base
|
15
|
+
|
16
|
+
set :show_exceptions, false
|
17
|
+
|
18
|
+
put %r{/(.+)} do
|
19
|
+
halt 413 if request.content_type == "application/x-www-form-urlencoded"
|
20
|
+
status 201 unless store.key?(key)
|
21
|
+
store[key] = OpenStruct.new(:content_type => request.content_type, :body => request.body.read)
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
|
25
|
+
get %r{/(.+)} do
|
26
|
+
halt 404 unless store.key?(key)
|
27
|
+
halt 200 if request.head?
|
28
|
+
content_type "application/octet-stream"
|
29
|
+
store[key].body
|
30
|
+
end
|
31
|
+
|
32
|
+
delete %r{/(.+)} do
|
33
|
+
halt 404 unless store.key?(key)
|
34
|
+
store.delete(key)
|
35
|
+
nil
|
36
|
+
end
|
37
|
+
|
38
|
+
def store
|
39
|
+
settings.backing_store
|
40
|
+
end
|
41
|
+
|
42
|
+
def key
|
43
|
+
params[:captures].first.tap do |key|
|
44
|
+
if key =~ /FAIL/ # simulate failure
|
45
|
+
fail "hell"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
let(:fake_storage_service) do
|
53
|
+
subclass = Class.new(FakeStorageService)
|
54
|
+
subclass.set :backing_store, backing_store
|
55
|
+
subclass
|
56
|
+
end
|
57
|
+
|
58
|
+
let(:http_storage_host) { "storeit.com" }
|
59
|
+
|
60
|
+
before do
|
61
|
+
ShamRack.mount(fake_storage_service, http_storage_host)
|
62
|
+
end
|
63
|
+
|
64
|
+
after do
|
65
|
+
ShamRack.unmount_all
|
66
|
+
end
|
67
|
+
|
68
|
+
subject do
|
69
|
+
described_class.new("http://#{http_storage_host}/object-prefix/")
|
70
|
+
end
|
71
|
+
|
72
|
+
before do
|
73
|
+
allow(subject).to receive(:retry_intervals).and_return([0.01, 0.02])
|
74
|
+
end
|
75
|
+
|
76
|
+
it_behaves_like Blobby::Store
|
77
|
+
|
78
|
+
describe "#write" do
|
79
|
+
|
80
|
+
let(:content) { "CONTENT" }
|
81
|
+
|
82
|
+
before do
|
83
|
+
subject["foobar"].write(content)
|
84
|
+
end
|
85
|
+
|
86
|
+
it "PUTs stuff in the remote store" do
|
87
|
+
expect(backing_store["object-prefix/foobar"].body).to eq(content)
|
88
|
+
end
|
89
|
+
|
90
|
+
it "presents it as binary data" do
|
91
|
+
expect(backing_store["object-prefix/foobar"].content_type).to eq("application/octet-stream")
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
|
96
|
+
context "when a server error occurs" do
|
97
|
+
|
98
|
+
let(:key) { "SERVER/FAIL" }
|
99
|
+
|
100
|
+
describe "#read" do
|
101
|
+
|
102
|
+
it "raises an exception" do
|
103
|
+
expect do
|
104
|
+
subject[key].read
|
105
|
+
end.to raise_error
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
|
110
|
+
describe "#write" do
|
111
|
+
|
112
|
+
it "raises an exception" do
|
113
|
+
expect do
|
114
|
+
subject[key].write("something")
|
115
|
+
end.to raise_error
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
|
122
|
+
[EOFError, Errno::ECONNRESET].each do |retryable_exception|
|
123
|
+
|
124
|
+
context "when a transient #{retryable_exception} occurs" do
|
125
|
+
|
126
|
+
before do
|
127
|
+
allow(Net::HTTP).to receive(:start) do
|
128
|
+
allow(Net::HTTP).to receive(:start).and_call_original
|
129
|
+
fail retryable_exception, "interruptus connecti"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
it "retries and recovers" do
|
134
|
+
subject["foo"].write("bar")
|
135
|
+
expect(subject["foo"].read).to eq("bar")
|
136
|
+
end
|
137
|
+
|
138
|
+
end
|
139
|
+
|
140
|
+
context "when #{retryable_exception} exceptions keep happening" do
|
141
|
+
|
142
|
+
before do
|
143
|
+
allow(Net::HTTP).to receive(:start) do
|
144
|
+
fail retryable_exception, "interruptus connecti"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
it "raises the final exception" do
|
149
|
+
expect do
|
150
|
+
subject["anything"].exists?
|
151
|
+
end.to raise_error(retryable_exception)
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|
157
|
+
|
158
|
+
context "when the hostname can't be resolved" do
|
159
|
+
|
160
|
+
before do
|
161
|
+
allow(Net::HTTP).to receive(:start) do
|
162
|
+
fail SocketError, "getaddrinfo: nodename nor servname provided, or not known"
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
it { is_expected.not_to be_available }
|
167
|
+
|
168
|
+
end
|
169
|
+
|
170
|
+
context "when HTTP server cannot be contacted" do
|
171
|
+
|
172
|
+
before do
|
173
|
+
allow(Net::HTTP).to receive(:start) do
|
174
|
+
fail Errno::ECONNREFUSED, "Connection refused - connect(2)"
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
it { is_expected.not_to be_available }
|
179
|
+
|
180
|
+
end
|
181
|
+
|
182
|
+
context "when the base_url does not include a trailing slash" do
|
183
|
+
|
184
|
+
subject do
|
185
|
+
described_class.new("http://#{http_storage_host}/prefix")
|
186
|
+
end
|
187
|
+
|
188
|
+
it "appends a trailing slash" do
|
189
|
+
expect(subject.base_uri.to_s).to eq("http://#{http_storage_host}/prefix/")
|
190
|
+
end
|
191
|
+
|
192
|
+
end
|
193
|
+
|
194
|
+
context "when the base_url does include a trailing slash" do
|
195
|
+
|
196
|
+
subject do
|
197
|
+
described_class.new("http://#{http_storage_host}/prefix/")
|
198
|
+
end
|
199
|
+
|
200
|
+
it "does not append another" do
|
201
|
+
expect(subject.base_uri.to_s).to eq("http://#{http_storage_host}/prefix/")
|
202
|
+
end
|
203
|
+
|
204
|
+
end
|
205
|
+
|
206
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require "blobby/filesystem_store"
|
2
|
+
require "blobby/key_transforming_store"
|
3
|
+
require "blobby/store_behaviour"
|
4
|
+
require "tmpdir"
|
5
|
+
|
6
|
+
describe Blobby::KeyTransformingStore do
|
7
|
+
|
8
|
+
let(:memory) { Hash.new }
|
9
|
+
|
10
|
+
subject do
|
11
|
+
described_class.new(Blobby::InMemoryStore.new(memory)) { |key| key }
|
12
|
+
end
|
13
|
+
|
14
|
+
it_behaves_like Blobby::Store
|
15
|
+
|
16
|
+
let(:content) { "CONTENT" }
|
17
|
+
|
18
|
+
context "with a noop key transforming strategy" do
|
19
|
+
|
20
|
+
describe "#write" do
|
21
|
+
|
22
|
+
let(:key) { "NAMESPACED/KEY" }
|
23
|
+
let(:transformed_key) { key }
|
24
|
+
|
25
|
+
before do
|
26
|
+
subject[key].write(content)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "uses the transformed key" do
|
30
|
+
expect(subject[key]).to exist
|
31
|
+
expect(memory.key?(transformed_key)).to be_truthy
|
32
|
+
end
|
33
|
+
|
34
|
+
it "has the correct contents" do
|
35
|
+
expect(subject[key].read).to eq(content)
|
36
|
+
expect(memory[transformed_key]).to eq(content)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
context "with a sharding key transformation strategy" do
|
43
|
+
|
44
|
+
subject do
|
45
|
+
described_class.new(Blobby::InMemoryStore.new(memory)) do |key|
|
46
|
+
[key[0, 2], key[2, 2], key[4, 2], key].join "/"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe "#write" do
|
51
|
+
|
52
|
+
let(:key) { "aabbccdd.png" }
|
53
|
+
let(:transformed_key) { "aa/bb/cc/aabbccdd.png" }
|
54
|
+
|
55
|
+
before do
|
56
|
+
subject[key].write(content)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "uses the transformed key" do
|
60
|
+
expect(subject[key]).to exist
|
61
|
+
expect(memory.key?(transformed_key)).to be_truthy
|
62
|
+
end
|
63
|
+
|
64
|
+
it "has the correct contents" do
|
65
|
+
expect(subject[key].read).to eq(content)
|
66
|
+
expect(memory[transformed_key]).to eq(content)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
context "with a shard ignoring key transformation strategy" do
|
73
|
+
|
74
|
+
subject do
|
75
|
+
described_class.new(Blobby::InMemoryStore.new(memory)) do |key|
|
76
|
+
key.split("/").last
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe "#write" do
|
81
|
+
|
82
|
+
let(:key) { "/aa/bb/cc/aabbccdd.png" }
|
83
|
+
let(:transformed_key) { "aabbccdd.png" }
|
84
|
+
|
85
|
+
before do
|
86
|
+
subject[key].write(content)
|
87
|
+
end
|
88
|
+
|
89
|
+
it "uses the transformed key" do
|
90
|
+
expect(subject[key]).to exist
|
91
|
+
expect(memory.key?(transformed_key)).to be_truthy
|
92
|
+
end
|
93
|
+
|
94
|
+
it "has the correct contents" do
|
95
|
+
expect(subject[key].read).to eq(content)
|
96
|
+
expect(memory[transformed_key]).to eq(content)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require "logger"
|
2
|
+
require "blobby/in_memory_store"
|
3
|
+
require "blobby/logging_store"
|
4
|
+
require "blobby/store_behaviour"
|
5
|
+
require "stringio"
|
6
|
+
|
7
|
+
describe Blobby::LoggingStore do
|
8
|
+
|
9
|
+
let(:backing_store) { Blobby::InMemoryStore.new }
|
10
|
+
let(:log_buffer) { StringIO.new }
|
11
|
+
let(:logger) { Logger.new(log_buffer) }
|
12
|
+
let(:log_output) { log_buffer.string }
|
13
|
+
|
14
|
+
subject do
|
15
|
+
described_class.new(backing_store, "THE STORE", logger)
|
16
|
+
end
|
17
|
+
|
18
|
+
it_behaves_like Blobby::Store
|
19
|
+
|
20
|
+
describe "#write" do
|
21
|
+
|
22
|
+
before do
|
23
|
+
subject["foo"].write("bar")
|
24
|
+
end
|
25
|
+
|
26
|
+
it "logs the write" do
|
27
|
+
expect(log_output).to include(%(wrote to "foo" in THE STORE))
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "#delete" do
|
33
|
+
|
34
|
+
before do
|
35
|
+
subject["foo"].write("bar")
|
36
|
+
subject["foo"].delete
|
37
|
+
end
|
38
|
+
|
39
|
+
it "logs the delete" do
|
40
|
+
expect(log_output).to include(%(deleted "foo" from THE STORE))
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
@@ -0,0 +1,174 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require "rspec"
|
4
|
+
|
5
|
+
module Blobby
|
6
|
+
module Store
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
shared_examples_for Blobby::Store do
|
11
|
+
|
12
|
+
let(:key) { "KEY" }
|
13
|
+
let(:content) { "CONTENT" }
|
14
|
+
|
15
|
+
it "is available" do
|
16
|
+
expect(subject).to be_available
|
17
|
+
end
|
18
|
+
|
19
|
+
context "for a valid key" do
|
20
|
+
|
21
|
+
let(:stored_object) { subject[key] }
|
22
|
+
|
23
|
+
context "when nothing has been stored" do
|
24
|
+
|
25
|
+
describe "#exists?" do
|
26
|
+
it "is false" do
|
27
|
+
expect(stored_object).not_to exist
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe "#read" do
|
32
|
+
it "returns nil" do
|
33
|
+
expect(stored_object.read).to be_nil
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe "#delete" do
|
38
|
+
it "returns false" do
|
39
|
+
expect(stored_object.delete).to eq(false)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
context "after content has been written" do
|
46
|
+
|
47
|
+
before do
|
48
|
+
stored_object.write(content)
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "#exists?" do
|
52
|
+
it "is true" do
|
53
|
+
expect(stored_object).to exist
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe "#read" do
|
58
|
+
|
59
|
+
it "returns the content" do
|
60
|
+
expect(stored_object.read).to eq(content)
|
61
|
+
end
|
62
|
+
|
63
|
+
context "with a block" do
|
64
|
+
|
65
|
+
before do
|
66
|
+
@chunks = []
|
67
|
+
@rval = stored_object.read do |chunk|
|
68
|
+
@chunks << chunk
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
it "yields the content in chunks" do
|
73
|
+
expect(@chunks.join).to eq(content)
|
74
|
+
end
|
75
|
+
|
76
|
+
it "returns nil" do
|
77
|
+
expect(@rval).to be_nil
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
|
84
|
+
describe "#delete" do
|
85
|
+
it "returns true" do
|
86
|
+
expect(stored_object.delete).to eq(true)
|
87
|
+
end
|
88
|
+
|
89
|
+
it "removes the object" do
|
90
|
+
stored_object.delete
|
91
|
+
expect(stored_object).not_to exist
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
if "Strings".respond_to?(:encoding)
|
98
|
+
|
99
|
+
context "for UTF-8 content" do
|
100
|
+
|
101
|
+
let(:content) { "SN☃WMAN" }
|
102
|
+
|
103
|
+
before do
|
104
|
+
stored_object.write(content)
|
105
|
+
end
|
106
|
+
|
107
|
+
describe "#read" do
|
108
|
+
|
109
|
+
it "returns binary data" do
|
110
|
+
expect(stored_object.read.encoding.name).to eq("ASCII-8BIT")
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
|
119
|
+
describe "#write" do
|
120
|
+
|
121
|
+
it "returns nil" do
|
122
|
+
expect(stored_object.write(content)).to be_nil
|
123
|
+
end
|
124
|
+
|
125
|
+
context "with a stream" do
|
126
|
+
|
127
|
+
before do
|
128
|
+
stored_object.write(StringIO.new(content))
|
129
|
+
end
|
130
|
+
|
131
|
+
it "writes the content of the stream" do
|
132
|
+
expect(stored_object.read).to eq(content)
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
|
137
|
+
end
|
138
|
+
|
139
|
+
end
|
140
|
+
|
141
|
+
class << self
|
142
|
+
|
143
|
+
def it_allows_keys_containing(description, example_key)
|
144
|
+
it "allows keys containing #{description}" do
|
145
|
+
expect { subject[example_key] }.not_to raise_error
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def it_disallows_keys_containing(description, example_key)
|
150
|
+
it "disallows keys containing #{description}" do
|
151
|
+
expect { subject[example_key] }.to raise_error(ArgumentError)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
end
|
156
|
+
|
157
|
+
it_disallows_keys_containing "nil", nil
|
158
|
+
it_disallows_keys_containing "blank", ""
|
159
|
+
|
160
|
+
it_allows_keys_containing "slashes", "foo/bar/baz"
|
161
|
+
it_allows_keys_containing "selected metacharacters", "@$&*.,;()~"
|
162
|
+
|
163
|
+
it_disallows_keys_containing "spaces", "foo bar"
|
164
|
+
it_disallows_keys_containing "tabs", "foo\tbar"
|
165
|
+
it_disallows_keys_containing "newlines", "foo\nbar"
|
166
|
+
|
167
|
+
it_disallows_keys_containing "a question mark", "foo?"
|
168
|
+
it_disallows_keys_containing "a colon", "foo:blah"
|
169
|
+
|
170
|
+
it_disallows_keys_containing "a leading slash", "/foo"
|
171
|
+
it_disallows_keys_containing "a trailing slash", "foo/"
|
172
|
+
it_disallows_keys_containing "double slashes", "foo//bar"
|
173
|
+
|
174
|
+
end
|