saviour 0.2.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/.travis.yml +10 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +491 -0
- data/Rakefile +6 -0
- data/lib/saviour.rb +170 -0
- data/lib/saviour/base_uploader.rb +81 -0
- data/lib/saviour/config.rb +13 -0
- data/lib/saviour/file.rb +124 -0
- data/lib/saviour/local_storage.rb +72 -0
- data/lib/saviour/processors/digest.rb +16 -0
- data/lib/saviour/s3_storage.rb +77 -0
- data/lib/saviour/string_source.rb +15 -0
- data/lib/saviour/uploader/element.rb +19 -0
- data/lib/saviour/uploader/processors_runner.rb +88 -0
- data/lib/saviour/uploader/store_dir_extractor.rb +41 -0
- data/lib/saviour/url_source.rb +55 -0
- data/lib/saviour/version.rb +3 -0
- data/saviour.gemspec +26 -0
- data/spec/feature/access_to_model_and_mounted_as.rb +30 -0
- data/spec/feature/crud_workflows_spec.rb +138 -0
- data/spec/feature/persisted_path_spec.rb +35 -0
- data/spec/feature/reload_model_spec.rb +25 -0
- data/spec/feature/validations_spec.rb +172 -0
- data/spec/feature/versions_spec.rb +186 -0
- data/spec/models/base_uploader_spec.rb +396 -0
- data/spec/models/config_spec.rb +16 -0
- data/spec/models/file_spec.rb +210 -0
- data/spec/models/local_storage_spec.rb +154 -0
- data/spec/models/processors/digest_spec.rb +22 -0
- data/spec/models/s3_storage_spec.rb +170 -0
- data/spec/models/saviour_spec.rb +80 -0
- data/spec/models/url_source_spec.rb +76 -0
- data/spec/spec_helper.rb +93 -0
- data/spec/support/data/camaloon.jpg +0 -0
- data/spec/support/data/example.xml +21 -0
- data/spec/support/data/text.txt +1 -0
- data/spec/support/models.rb +3 -0
- data/spec/support/schema.rb +9 -0
- metadata +196 -0
@@ -0,0 +1,16 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Processors
|
3
|
+
module Digest
|
4
|
+
def digest_filename(contents, filename, opts = {})
|
5
|
+
separator = opts.fetch(:separator, "-")
|
6
|
+
|
7
|
+
digest = ::Digest::MD5.hexdigest(contents)
|
8
|
+
extension = ::File.extname(filename)
|
9
|
+
|
10
|
+
new_filename = "#{[::File.basename(filename, ".*"), digest].join(separator)}#{extension}"
|
11
|
+
|
12
|
+
[contents, new_filename]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module Saviour
|
2
|
+
class S3Storage
|
3
|
+
def initialize(conf = {})
|
4
|
+
@bucket = conf.delete(:bucket)
|
5
|
+
@public_url_prefix = conf.delete(:public_url_prefix)
|
6
|
+
@conf = conf
|
7
|
+
@overwrite_protection = conf.delete(:overwrite_protection) { true }
|
8
|
+
assert_directory_exists!
|
9
|
+
end
|
10
|
+
|
11
|
+
def write(contents, path)
|
12
|
+
raise(RuntimeError, "The path you're trying to write already exists!") if @overwrite_protection && exists?(path)
|
13
|
+
|
14
|
+
path = sanitize_leading_slash(path)
|
15
|
+
directory.files.create(
|
16
|
+
key: path,
|
17
|
+
body: contents,
|
18
|
+
public: true
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def read(path)
|
23
|
+
path = sanitize_leading_slash(path)
|
24
|
+
assert_exists(path)
|
25
|
+
directory.files.get(path).body
|
26
|
+
end
|
27
|
+
|
28
|
+
def delete(path)
|
29
|
+
path = sanitize_leading_slash(path)
|
30
|
+
assert_exists(path)
|
31
|
+
directory.files.get(path).destroy
|
32
|
+
end
|
33
|
+
|
34
|
+
def exists?(path)
|
35
|
+
path = sanitize_leading_slash(path)
|
36
|
+
!!directory.files.head(path)
|
37
|
+
end
|
38
|
+
|
39
|
+
def public_url(path)
|
40
|
+
raise(RuntimeError, "You must provide a `public_url_prefix`") unless public_url_prefix
|
41
|
+
|
42
|
+
path = sanitize_leading_slash(path)
|
43
|
+
::File.join(public_url_prefix, path)
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def public_url_prefix
|
50
|
+
if @public_url_prefix.respond_to?(:call)
|
51
|
+
@public_url_prefix.call
|
52
|
+
else
|
53
|
+
@public_url_prefix
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def sanitize_leading_slash(path)
|
58
|
+
path.gsub(/\A\/*/, '')
|
59
|
+
end
|
60
|
+
|
61
|
+
def assert_directory_exists!
|
62
|
+
directory || raise(ArgumentError, "The bucket #{@bucket} doesn't exists or misconfigured connection.")
|
63
|
+
end
|
64
|
+
|
65
|
+
def assert_exists(path)
|
66
|
+
raise RuntimeError, "File does not exists: #{path}" unless exists?(path)
|
67
|
+
end
|
68
|
+
|
69
|
+
def directory
|
70
|
+
@directory ||= connection.directories.get(@bucket)
|
71
|
+
end
|
72
|
+
|
73
|
+
def connection
|
74
|
+
@connection ||= Fog::Storage.new({provider: 'AWS'}.merge(@conf))
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Uploader
|
3
|
+
class Element
|
4
|
+
attr_reader :version, :method_or_block
|
5
|
+
|
6
|
+
def initialize(version, method_or_block)
|
7
|
+
@version, @method_or_block = version, method_or_block
|
8
|
+
end
|
9
|
+
|
10
|
+
def versioned?
|
11
|
+
!!@version
|
12
|
+
end
|
13
|
+
|
14
|
+
def block?
|
15
|
+
@method_or_block.respond_to?(:call)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Uploader
|
3
|
+
class ProcessorsRunner
|
4
|
+
attr_writer :file
|
5
|
+
attr_accessor :contents
|
6
|
+
attr_accessor :filename
|
7
|
+
|
8
|
+
def initialize(uploader, version_name)
|
9
|
+
@uploader, @version_name = uploader, version_name
|
10
|
+
end
|
11
|
+
|
12
|
+
def matching_processors
|
13
|
+
@uploader.class.processors.select { |processor| !processor[:element].versioned? || processor[:element].version == @version_name }
|
14
|
+
end
|
15
|
+
|
16
|
+
def file
|
17
|
+
@file ||= Tempfile.new([SecureRandom.hex, ::File.extname(filename)]).tap { |x| x.binmode }
|
18
|
+
end
|
19
|
+
|
20
|
+
def run_element(element, opts, data)
|
21
|
+
if element.block?
|
22
|
+
@uploader.instance_exec(data, filename, &element.method_or_block)
|
23
|
+
else
|
24
|
+
if opts.empty?
|
25
|
+
@uploader.send(element.method_or_block, data, filename)
|
26
|
+
else
|
27
|
+
@uploader.send(element.method_or_block, data, filename, opts)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def advance!(processor, previous_type)
|
33
|
+
if processor[:type] != previous_type
|
34
|
+
if processor[:type] == :memory
|
35
|
+
self.contents = ::File.read(file)
|
36
|
+
else
|
37
|
+
file.rewind
|
38
|
+
file.write(contents)
|
39
|
+
file.flush
|
40
|
+
file.rewind
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def run_processor(processor)
|
46
|
+
element = processor[:element]
|
47
|
+
opts = processor[:opts]
|
48
|
+
|
49
|
+
if processor[:type] == :memory
|
50
|
+
result = run_element(element, opts, contents)
|
51
|
+
|
52
|
+
self.contents = result[0]
|
53
|
+
self.filename = result[1]
|
54
|
+
|
55
|
+
else
|
56
|
+
file.rewind
|
57
|
+
|
58
|
+
result = run_element(element, opts, file)
|
59
|
+
|
60
|
+
self.file = result[0]
|
61
|
+
self.filename = result[1]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def run!(content_data, initial_filename)
|
66
|
+
self.contents = content_data
|
67
|
+
self.filename = initial_filename
|
68
|
+
previous_type = :memory
|
69
|
+
|
70
|
+
matching_processors.each do |processor|
|
71
|
+
advance!(processor, previous_type)
|
72
|
+
|
73
|
+
run_processor(processor)
|
74
|
+
previous_type = processor[:type]
|
75
|
+
end
|
76
|
+
|
77
|
+
if previous_type == :file
|
78
|
+
file.rewind
|
79
|
+
self.contents = ::File.read(file)
|
80
|
+
end
|
81
|
+
|
82
|
+
file.delete
|
83
|
+
|
84
|
+
[contents, filename]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Uploader
|
3
|
+
class StoreDirExtractor
|
4
|
+
def initialize(uploader)
|
5
|
+
@uploader = uploader
|
6
|
+
end
|
7
|
+
|
8
|
+
def candidate_store_dirs
|
9
|
+
@candidate_store_dirs ||= @uploader.class.store_dirs
|
10
|
+
end
|
11
|
+
|
12
|
+
def versioned_store_dirs?
|
13
|
+
candidate_store_dirs.any? { |x| x.versioned? && x.version == @uploader.version_name }
|
14
|
+
end
|
15
|
+
|
16
|
+
def versioned_store_dir
|
17
|
+
candidate_store_dirs.select { |x| x.versioned? && x.version == @uploader.version_name }.last if versioned_store_dirs?
|
18
|
+
end
|
19
|
+
|
20
|
+
def non_versioned_store_dir
|
21
|
+
candidate_store_dirs.select { |x| !x.versioned? }.last
|
22
|
+
end
|
23
|
+
|
24
|
+
def store_dir_handler
|
25
|
+
@store_dir_handler ||= versioned_store_dir || non_versioned_store_dir
|
26
|
+
end
|
27
|
+
|
28
|
+
def store_dir
|
29
|
+
@store_dir ||= begin
|
30
|
+
if store_dir_handler
|
31
|
+
if store_dir_handler.block?
|
32
|
+
@uploader.instance_eval(&store_dir_handler.method_or_block)
|
33
|
+
else
|
34
|
+
@uploader.send(store_dir_handler.method_or_block)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
module Saviour
|
4
|
+
class UrlSource
|
5
|
+
MAX_REDIRECTS = 10
|
6
|
+
|
7
|
+
def initialize(url)
|
8
|
+
@uri = wrap_uri_string(url)
|
9
|
+
end
|
10
|
+
|
11
|
+
def read
|
12
|
+
with_retry(3) { resolve(@uri) }
|
13
|
+
end
|
14
|
+
|
15
|
+
def original_filename
|
16
|
+
::File.basename(@uri.path)
|
17
|
+
end
|
18
|
+
|
19
|
+
def path
|
20
|
+
@uri.path
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def resolve(uri, max_redirects = MAX_REDIRECTS)
|
27
|
+
raise RuntimeError, "Max number of allowed redirects reached (#{MAX_REDIRECTS}) when resolving #{@uri}" if max_redirects == 0
|
28
|
+
|
29
|
+
response = Net::HTTP.get_response(uri)
|
30
|
+
|
31
|
+
case response
|
32
|
+
when Net::HTTPSuccess
|
33
|
+
response.body
|
34
|
+
when Net::HTTPRedirection
|
35
|
+
resolve(wrap_uri_string(response['location']), max_redirects - 1)
|
36
|
+
else
|
37
|
+
false
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def wrap_uri_string(url)
|
42
|
+
begin
|
43
|
+
URI(url)
|
44
|
+
rescue URI::InvalidURIError
|
45
|
+
raise ArgumentError, "'#{url}' is not a valid URI"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def with_retry(n = 3, &block)
|
50
|
+
raise(RuntimeError, "Connection to #{@uri} failed after 3 attempts.") if n == 0
|
51
|
+
|
52
|
+
block.call || with_retry(n - 1, &block)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/saviour.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
require './lib/saviour/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "saviour"
|
5
|
+
spec.version = Saviour::VERSION
|
6
|
+
spec.authors = ["Roger Campos"]
|
7
|
+
spec.email = ["roger@rogercampos.com"]
|
8
|
+
spec.description = %q{File storage handler following active record model lifecycle}
|
9
|
+
spec.summary = %q{File storage handler following active record model lifecycle}
|
10
|
+
spec.homepage = "https://github.com/rogercampos/saviour"
|
11
|
+
spec.license = "MIT"
|
12
|
+
|
13
|
+
spec.files = `git ls-files`.split($/)
|
14
|
+
spec.require_paths = ["lib"]
|
15
|
+
|
16
|
+
spec.required_ruby_version = ">= 2.0.0"
|
17
|
+
|
18
|
+
spec.add_dependency "activerecord", ">= 3.0"
|
19
|
+
spec.add_dependency "activesupport", ">= 3.0"
|
20
|
+
spec.add_dependency "fog-aws"
|
21
|
+
spec.add_dependency "mime-types"
|
22
|
+
spec.add_development_dependency "bundler"
|
23
|
+
spec.add_development_dependency "rake"
|
24
|
+
spec.add_development_dependency "rspec"
|
25
|
+
spec.add_development_dependency "sqlite3"
|
26
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe "access to model data from uploaders" do
|
4
|
+
before { Saviour::Config.storage = Saviour::LocalStorage.new(local_prefix: @tmpdir, public_url_prefix: "http://domain.com") }
|
5
|
+
after { Saviour::Config.storage = nil }
|
6
|
+
|
7
|
+
let(:uploader) {
|
8
|
+
Class.new(Saviour::BaseUploader) do
|
9
|
+
store_dir { "/store/dir/#{model.id}" }
|
10
|
+
process { |contents, name| [contents, "#{model.id}-#{attached_as}-#{name}"] }
|
11
|
+
end
|
12
|
+
}
|
13
|
+
|
14
|
+
let(:klass) {
|
15
|
+
a = Class.new(Text)
|
16
|
+
a.attach_file :file, uploader
|
17
|
+
a
|
18
|
+
}
|
19
|
+
|
20
|
+
describe "file store" do
|
21
|
+
it do
|
22
|
+
with_test_file("example.xml") do |example, name|
|
23
|
+
a = klass.create! id: 87
|
24
|
+
expect(a.update_attributes(file: example)).to be_truthy
|
25
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
26
|
+
expect(a[:file]).to eq "/store/dir/87/87-file-#{name}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe "saving a new file" do
|
4
|
+
before { Saviour::Config.storage = Saviour::LocalStorage.new(local_prefix: @tmpdir, public_url_prefix: "http://domain.com") }
|
5
|
+
after { Saviour::Config.storage = nil }
|
6
|
+
|
7
|
+
let(:uploader) {
|
8
|
+
Class.new(Saviour::BaseUploader) {
|
9
|
+
store_dir { "/store/dir" }
|
10
|
+
}
|
11
|
+
}
|
12
|
+
|
13
|
+
let(:klass) {
|
14
|
+
a = Class.new(Test) { include Saviour }
|
15
|
+
a.attach_file :file, uploader
|
16
|
+
a
|
17
|
+
}
|
18
|
+
|
19
|
+
describe "creation" do
|
20
|
+
it do
|
21
|
+
with_test_file("example.xml") do |example|
|
22
|
+
a = klass.create!
|
23
|
+
expect(a.update_attributes(file: example)).to be_truthy
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
it do
|
28
|
+
with_test_file("example.xml") do |example|
|
29
|
+
a = klass.create!
|
30
|
+
a.update_attributes(file: example)
|
31
|
+
|
32
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
it do
|
37
|
+
with_test_file("example.xml") do |example, real_filename|
|
38
|
+
a = klass.create!
|
39
|
+
a.update_attributes(file: example)
|
40
|
+
expect(a[:file]).to eq "/store/dir/#{real_filename}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it do
|
45
|
+
with_test_file("example.xml") do |example|
|
46
|
+
a = klass.create!
|
47
|
+
a.update_attributes(file: example)
|
48
|
+
|
49
|
+
example.rewind
|
50
|
+
expect(a.file.read).to eq example.read
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
it do
|
55
|
+
with_test_file("example.xml") do |example|
|
56
|
+
a = klass.create!
|
57
|
+
a.update_attributes(file: example)
|
58
|
+
|
59
|
+
expect(a.file.exists?).to be_truthy
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
it do
|
64
|
+
with_test_file("example.xml") do |example, real_filename|
|
65
|
+
a = klass.create!
|
66
|
+
a.update_attributes(file: example)
|
67
|
+
|
68
|
+
expect(a.file.filename).to eq real_filename
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
it do
|
73
|
+
with_test_file("example.xml") do |example, real_filename|
|
74
|
+
a = klass.create!
|
75
|
+
a.update_attributes(file: example)
|
76
|
+
|
77
|
+
expect(a.file.url).to eq "http://domain.com/store/dir/#{real_filename}"
|
78
|
+
expect(a.file.public_url).to eq a.file.url
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
it "don't create anything if save do not completes (halt during before_save)" do
|
83
|
+
klass = Class.new(Test) do
|
84
|
+
attr_accessor :fail_at_save
|
85
|
+
before_save { !fail_at_save }
|
86
|
+
include Saviour
|
87
|
+
end
|
88
|
+
klass.attach_file :file, uploader
|
89
|
+
|
90
|
+
expect {
|
91
|
+
a = klass.new
|
92
|
+
a.fail_at_save = true
|
93
|
+
a.save!
|
94
|
+
}.to raise_error(ActiveRecord::RecordNotSaved)
|
95
|
+
|
96
|
+
with_test_file("example.xml") do |example, _|
|
97
|
+
a = klass.new
|
98
|
+
a.fail_at_save = true
|
99
|
+
a.file = example
|
100
|
+
|
101
|
+
expect(Saviour::Config.storage).not_to receive(:write)
|
102
|
+
a.save
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
describe "deletion" do
|
108
|
+
it do
|
109
|
+
with_test_file("example.xml") do |example|
|
110
|
+
a = klass.create!
|
111
|
+
a.update_attributes(file: example)
|
112
|
+
expect(a.file.exists?).to be_truthy
|
113
|
+
expect(a.destroy).to be_truthy
|
114
|
+
|
115
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_falsey
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
describe "updating" do
|
121
|
+
it do
|
122
|
+
with_test_file("example.xml") do |example|
|
123
|
+
a = klass.create!
|
124
|
+
a.update_attributes(file: example)
|
125
|
+
|
126
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
127
|
+
previous_location = a[:file]
|
128
|
+
|
129
|
+
with_test_file("camaloon.jpg") do |example_2|
|
130
|
+
a.update_attributes(file: example_2)
|
131
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
132
|
+
|
133
|
+
expect(Saviour::Config.storage.exists?(previous_location)).to be_falsey
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|