blobby 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,64 @@
1
+ require "blobby/key_constraint"
2
+
3
+ module Blobby
4
+
5
+ # A BLOB store backed by a Hash.
6
+ #
7
+ class InMemoryStore
8
+
9
+ def initialize(hash = {})
10
+ @hash = hash
11
+ end
12
+
13
+ def available?
14
+ true
15
+ end
16
+
17
+ def [](key)
18
+ KeyConstraint.must_allow!(key)
19
+ StoredObject.new(@hash, key)
20
+ end
21
+
22
+ class StoredObject
23
+
24
+ def initialize(hash, key)
25
+ @hash = hash
26
+ @key = key
27
+ end
28
+
29
+ attr_reader :key
30
+
31
+ def exists?
32
+ @hash.key?(key)
33
+ end
34
+
35
+ def read
36
+ content = @hash[key]
37
+ if block_given?
38
+ yield content
39
+ nil
40
+ else
41
+ content
42
+ end
43
+ end
44
+
45
+ def write(content)
46
+ if content.respond_to?(:read)
47
+ content = content.read
48
+ else
49
+ content = content.to_str.dup
50
+ end
51
+ content = content.force_encoding("BINARY") if content.respond_to?(:force_encoding)
52
+ @hash[key] = content
53
+ nil
54
+ end
55
+
56
+ def delete
57
+ !!@hash.delete(key)
58
+ end
59
+
60
+ end
61
+
62
+ end
63
+
64
+ end
@@ -0,0 +1,34 @@
1
+ require "uri"
2
+
3
+ module Blobby
4
+
5
+ # Defines the keys we allow for use in BLOB-store implementations.
6
+ #
7
+ # Basically, we allow anything that would be a valid URI "path" component.
8
+ #
9
+ module KeyConstraint
10
+
11
+ extend self
12
+
13
+ BAD_PATTERNS = [
14
+ %r{\A\Z}, # blank
15
+ %r{\A/}, # leading slash
16
+ %r{/\Z}, # trailing slash
17
+ %r{//+}, # multiple slashes
18
+ %r{:} # colon
19
+ ].freeze
20
+
21
+ def allows?(key)
22
+ BAD_PATTERNS.none? { |pattern| pattern =~ key } &&
23
+ URI.parse(key).path == key
24
+ rescue URI::InvalidURIError
25
+ false
26
+ end
27
+
28
+ def must_allow!(key)
29
+ fail ArgumentError, "invalid key: #{key.inspect}" unless allows?(key)
30
+ end
31
+
32
+ end
33
+
34
+ end
@@ -0,0 +1,25 @@
1
+ require "blobby/key_constraint"
2
+ require "fileutils"
3
+ require "forwardable"
4
+ require "pathname"
5
+ require "tempfile"
6
+
7
+ module Blobby
8
+
9
+ # A BLOB store that decorates another store and allows key transformation
10
+ #
11
+ class KeyTransformingStore < SimpleDelegator
12
+
13
+ def initialize(store, &key_transformation_strategy)
14
+ super(store)
15
+ @key_transformation_strategy = key_transformation_strategy
16
+ end
17
+
18
+ def [](key)
19
+ transformed_key = @key_transformation_strategy.call(key)
20
+ __getobj__[transformed_key]
21
+ end
22
+
23
+ end
24
+
25
+ end
@@ -0,0 +1,67 @@
1
+ module Blobby
2
+
3
+ # A store decorator that logs writes and deletes
4
+ #
5
+ class LoggingStore
6
+
7
+ def initialize(store, store_name, logger)
8
+ @store = store
9
+ @store_name = store_name
10
+ @logger = logger
11
+ end
12
+
13
+ def available?
14
+ store.available?
15
+ end
16
+
17
+ def [](key)
18
+ StoredObject.new(store[key],
19
+ :on_write => -> { logger.info(%(wrote to #{key.inspect} in #{store_name})) },
20
+ :on_delete => -> { logger.info(%(deleted #{key.inspect} from #{store_name})) }
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :logger
27
+ attr_reader :store
28
+ attr_reader :store_name
29
+
30
+ class StoredObject
31
+
32
+ def initialize(object, callbacks = {})
33
+ @object = object
34
+ @on_write = callbacks[:on_write] || -> {}
35
+ @on_delete = callbacks[:on_delete] || -> {}
36
+ end
37
+
38
+ def exists?
39
+ @object.exists?
40
+ end
41
+
42
+ def read(&block)
43
+ @object.read(&block)
44
+ end
45
+
46
+ def write(*args)
47
+ @object.write(*args)
48
+ @on_write.call
49
+ nil
50
+ end
51
+
52
+ def delete
53
+ deleted = @object.delete
54
+ @on_delete.call if deleted
55
+ deleted
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :object
61
+ attr_reader :on_write
62
+
63
+ end
64
+
65
+ end
66
+
67
+ end
Binary file
@@ -0,0 +1,3 @@
1
+ module Blobby
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,54 @@
1
+ require "blobby/composite_store"
2
+ require "blobby/in_memory_store"
3
+ require "blobby/store_behaviour"
4
+
5
+ describe Blobby::CompositeStore do
6
+
7
+ let(:storeA) { Blobby::InMemoryStore.new }
8
+ let(:storeB) { Blobby::InMemoryStore.new }
9
+ let(:stores) { [storeA, storeB] }
10
+
11
+ subject do
12
+ described_class.new(stores)
13
+ end
14
+
15
+ it_behaves_like Blobby::Store
16
+
17
+ let(:key) { "KEY" }
18
+ let(:content) { "CONTENT" }
19
+
20
+ describe "#write" do
21
+
22
+ before do
23
+ subject[key].write(content)
24
+ end
25
+
26
+ it "writes to all stores" do
27
+ stores.all? do |store|
28
+ expect(store[key].read).to eq(content)
29
+ end
30
+ end
31
+
32
+ end
33
+
34
+ context "for a key that only exists in the second store" do
35
+
36
+ before do
37
+ storeB[key].write(content)
38
+ end
39
+
40
+ describe "#exists?" do
41
+ it "is true" do
42
+ expect(subject[key]).to exist
43
+ end
44
+ end
45
+
46
+ describe "#read" do
47
+ it "returns the content" do
48
+ expect(subject[key].read).to eq(content)
49
+ end
50
+ end
51
+
52
+ end
53
+
54
+ end
@@ -0,0 +1,134 @@
1
+ # encoding: UTF-8
2
+
3
+ require "blobby/fake_success_store"
4
+ require "blobby/store_behaviour"
5
+
6
+ describe Blobby::FakeSuccessStore do
7
+
8
+ subject do
9
+ described_class.new
10
+ end
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 "after content has been written" do
24
+
25
+ before do
26
+ stored_object.write(content)
27
+ end
28
+
29
+ describe "#exists?" do
30
+ it "is true" do
31
+ expect(stored_object).to exist
32
+ end
33
+ end
34
+
35
+ describe "#read" do
36
+
37
+ it "returns some content" do
38
+ expect(stored_object.read).not_to be_nil
39
+ end
40
+
41
+ context "with a block" do
42
+
43
+ before do
44
+ @chunks = []
45
+ @rval = stored_object.read do |chunk|
46
+ @chunks << chunk
47
+ end
48
+ end
49
+
50
+ it "yields some content in chunks" do
51
+ expect(@chunks.join).not_to be_nil
52
+ end
53
+
54
+ it "returns nil" do
55
+ expect(@rval).to be_nil
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+
62
+ describe "#delete" do
63
+ it "returns true" do
64
+ expect(stored_object.delete).to eq(true)
65
+ end
66
+ end
67
+ end
68
+
69
+ if "Strings".respond_to?(:encoding)
70
+
71
+ context "for UTF-8 content" do
72
+
73
+ let(:content) { "SN☃WMAN" }
74
+
75
+ before do
76
+ stored_object.write(content)
77
+ end
78
+
79
+ describe "#read" do
80
+
81
+ it "returns binary data" do
82
+ expect(stored_object.read.encoding.name).to eq("ASCII-8BIT")
83
+ end
84
+
85
+ end
86
+
87
+ end
88
+
89
+ end
90
+
91
+ describe "#write" do
92
+
93
+ it "returns nil" do
94
+ expect(stored_object.write(content)).to be_nil
95
+ end
96
+
97
+ end
98
+
99
+ end
100
+
101
+ class << self
102
+
103
+ def it_allows_keys_containing(description, example_key)
104
+ it "allows keys containing #{description}" do
105
+ expect { subject[example_key] }.not_to raise_error
106
+ end
107
+ end
108
+
109
+ def it_disallows_keys_containing(description, example_key)
110
+ it "disallows keys containing #{description}" do
111
+ expect { subject[example_key] }.to raise_error(ArgumentError)
112
+ end
113
+ end
114
+
115
+ end
116
+
117
+ it_disallows_keys_containing "nil", nil
118
+ it_disallows_keys_containing "blank", ""
119
+
120
+ it_allows_keys_containing "slashes", "foo/bar/baz"
121
+ it_allows_keys_containing "selected metacharacters", "@$&*.,;()~"
122
+
123
+ it_disallows_keys_containing "spaces", "foo bar"
124
+ it_disallows_keys_containing "tabs", "foo\tbar"
125
+ it_disallows_keys_containing "newlines", "foo\nbar"
126
+
127
+ it_disallows_keys_containing "a question mark", "foo?"
128
+ it_disallows_keys_containing "a colon", "foo:blah"
129
+
130
+ it_disallows_keys_containing "a leading slash", "/foo"
131
+ it_disallows_keys_containing "a trailing slash", "foo/"
132
+ it_disallows_keys_containing "double slashes", "foo//bar"
133
+
134
+ end
@@ -0,0 +1,206 @@
1
+ # encoding: utf-8
2
+
3
+ require "blobby/filesystem_store"
4
+ require "blobby/store_behaviour"
5
+ require "tmpdir"
6
+
7
+ describe Blobby::FilesystemStore do
8
+
9
+ around(:each) do |example|
10
+ Dir.mktmpdir do |tmpdir|
11
+ @tmpdir = tmpdir
12
+ example.run
13
+ @tmpdir = nil
14
+ end
15
+ end
16
+
17
+ around(:each) do |example|
18
+ original_umask = File.umask
19
+ begin
20
+ File.umask(0077) # something stupid
21
+ example.run
22
+ ensure
23
+ File.umask(original_umask)
24
+ end
25
+ end
26
+
27
+ subject do
28
+ described_class.new(@tmpdir)
29
+ end
30
+
31
+ it_behaves_like Blobby::Store
32
+
33
+ let(:key) { "NAMESPACED/KEY" }
34
+ let(:content) { "CONTENT" }
35
+
36
+ let(:expected_file_path) { Pathname(@tmpdir) + key }
37
+
38
+ describe "#write" do
39
+
40
+ it "writes to the file-system" do
41
+ expect { subject[key].write(content) }.to change { expected_file_path.exist? }.from(false).to(true)
42
+ end
43
+
44
+ it "should have correct contents" do
45
+ expect { subject[key].write(content) }.to change { File.read(expected_file_path) rescue nil }.from(nil).to(content)
46
+ end
47
+
48
+ it "retries if renaming throws an ESTALE" do
49
+ raise_stack = [Errno::ESTALE]
50
+ expect_any_instance_of(Pathname).to receive(:rename).twice do |_args|
51
+ fail(raise_stack.shift) unless raise_stack.empty?
52
+ 1
53
+ end
54
+ expect { subject[key].write(content) }.to_not raise_error
55
+ end
56
+
57
+ end
58
+
59
+ context "with a sharding strategy" do
60
+
61
+ subject do
62
+ described_class.new(@tmpdir) do |key|
63
+ [key[0, 2], key[2, 2], key[4, 2], key].join "/"
64
+ end
65
+ end
66
+
67
+ describe "#write" do
68
+
69
+ let(:key) { "aabbccdd.png" }
70
+ let(:expected_file_path) { Pathname(@tmpdir) + "aa/bb/cc/aabbccdd.png" }
71
+
72
+ before do
73
+ subject[key].write(content)
74
+ end
75
+
76
+ it "uses the key as the filename" do
77
+ expect(expected_file_path).to exist
78
+ end
79
+
80
+ end
81
+
82
+ end
83
+
84
+ context "when the directory doesn't exist" do
85
+
86
+ subject do
87
+ described_class.new("/tmp/bogus/directory")
88
+ end
89
+
90
+ it { is_expected.not_to be_available }
91
+
92
+ end
93
+
94
+ context "when the directory isn't writable" do
95
+
96
+ around do |example|
97
+ FileUtils.chmod(0500, @tmpdir)
98
+ example.run
99
+ FileUtils.chmod(0700, @tmpdir)
100
+ end
101
+
102
+ it { is_expected.not_to be_available }
103
+
104
+ end
105
+
106
+ context "when the directory isn't readable" do
107
+
108
+ around do |example|
109
+ FileUtils.chmod(0300, @tmpdir)
110
+ example.run
111
+ FileUtils.chmod(0700, @tmpdir)
112
+ end
113
+
114
+ it { is_expected.not_to be_available }
115
+
116
+ end
117
+
118
+ context "when the path isn't a directory" do
119
+
120
+ subject do
121
+ tempfile = "#{@tmpdir}/tempfile"
122
+ FileUtils.touch(tempfile)
123
+ described_class.new(tempfile)
124
+ end
125
+
126
+ it { is_expected.not_to be_available }
127
+
128
+ end
129
+
130
+ context "when an IO error occurs" do
131
+
132
+ before do
133
+ allow_any_instance_of(IO).to receive(:write) do
134
+ fail IOError
135
+ end
136
+ end
137
+
138
+ describe "#write" do
139
+
140
+ before do
141
+ expect do
142
+ subject[key].write(content)
143
+ end.to raise_error(IOError)
144
+ end
145
+
146
+ it "doesn't write anything" do
147
+ expect(subject[key]).not_to exist
148
+ end
149
+
150
+ end
151
+
152
+ end
153
+
154
+ def mode_string_of(path)
155
+ format("0%o", path.stat.mode & 0777)
156
+ end
157
+
158
+ context "with a umask of 0027" do
159
+
160
+ subject do
161
+ described_class.new(@tmpdir, :umask => 0027)
162
+ end
163
+
164
+ it "has the specified umask" do
165
+ expect(subject.umask).to eq(0027)
166
+ end
167
+
168
+ describe "#write" do
169
+
170
+ before do
171
+ subject[key].write(content)
172
+ end
173
+
174
+ it "creates files with mode 0640" do
175
+ expect(mode_string_of(expected_file_path)).to eq("0640")
176
+ end
177
+
178
+ it "creates directories with mode 0750" do
179
+ expect(mode_string_of(expected_file_path.parent)).to eq("0750")
180
+ end
181
+
182
+ end
183
+
184
+ end
185
+
186
+ context "without an explicit umask" do
187
+
188
+ let(:system_umask) { 0024 }
189
+
190
+ around(:each) do |example|
191
+ original_umask = File.umask
192
+ begin
193
+ File.umask(system_umask)
194
+ example.run
195
+ ensure
196
+ File.umask(original_umask)
197
+ end
198
+ end
199
+
200
+ it "uses the system default umask" do
201
+ expect(subject.umask).to eq(system_umask)
202
+ end
203
+
204
+ end
205
+
206
+ end