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.
@@ -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