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,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,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
|