saviour 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.travis.yml +10 -0
  4. data/Gemfile +6 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +491 -0
  7. data/Rakefile +6 -0
  8. data/lib/saviour.rb +170 -0
  9. data/lib/saviour/base_uploader.rb +81 -0
  10. data/lib/saviour/config.rb +13 -0
  11. data/lib/saviour/file.rb +124 -0
  12. data/lib/saviour/local_storage.rb +72 -0
  13. data/lib/saviour/processors/digest.rb +16 -0
  14. data/lib/saviour/s3_storage.rb +77 -0
  15. data/lib/saviour/string_source.rb +15 -0
  16. data/lib/saviour/uploader/element.rb +19 -0
  17. data/lib/saviour/uploader/processors_runner.rb +88 -0
  18. data/lib/saviour/uploader/store_dir_extractor.rb +41 -0
  19. data/lib/saviour/url_source.rb +55 -0
  20. data/lib/saviour/version.rb +3 -0
  21. data/saviour.gemspec +26 -0
  22. data/spec/feature/access_to_model_and_mounted_as.rb +30 -0
  23. data/spec/feature/crud_workflows_spec.rb +138 -0
  24. data/spec/feature/persisted_path_spec.rb +35 -0
  25. data/spec/feature/reload_model_spec.rb +25 -0
  26. data/spec/feature/validations_spec.rb +172 -0
  27. data/spec/feature/versions_spec.rb +186 -0
  28. data/spec/models/base_uploader_spec.rb +396 -0
  29. data/spec/models/config_spec.rb +16 -0
  30. data/spec/models/file_spec.rb +210 -0
  31. data/spec/models/local_storage_spec.rb +154 -0
  32. data/spec/models/processors/digest_spec.rb +22 -0
  33. data/spec/models/s3_storage_spec.rb +170 -0
  34. data/spec/models/saviour_spec.rb +80 -0
  35. data/spec/models/url_source_spec.rb +76 -0
  36. data/spec/spec_helper.rb +93 -0
  37. data/spec/support/data/camaloon.jpg +0 -0
  38. data/spec/support/data/example.xml +21 -0
  39. data/spec/support/data/text.txt +1 -0
  40. data/spec/support/models.rb +3 -0
  41. data/spec/support/schema.rb +9 -0
  42. 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,15 @@
1
+ module Saviour
2
+ class StringSource
3
+ def initialize(value, filename = nil)
4
+ @value, @filename = value, filename
5
+ end
6
+
7
+ def read
8
+ @value
9
+ end
10
+
11
+ def original_filename
12
+ @filename
13
+ end
14
+ end
15
+ 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
@@ -0,0 +1,3 @@
1
+ module Saviour
2
+ VERSION = "0.2.0"
3
+ end
@@ -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