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,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,170 @@
1
+ require 'fileutils'
2
+ require 'digest/md5'
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext'
5
+ require 'active_support/core_ext/module/attribute_accessors'
6
+ require 'fog/aws'
7
+
8
+ require 'saviour/processors/digest'
9
+
10
+ require 'saviour/version'
11
+ require 'saviour/base_uploader'
12
+ require 'saviour/file'
13
+ require 'saviour/local_storage'
14
+ require 'saviour/s3_storage'
15
+ require 'saviour/config'
16
+ require 'saviour/string_source'
17
+ require 'saviour/url_source'
18
+
19
+ module Saviour
20
+ class ColumnNamer
21
+ def initialize(attached_as, version = nil)
22
+ @attached_as, @version = attached_as, version
23
+ end
24
+
25
+ def name
26
+ if @version
27
+ "#{@attached_as}_#{@version}"
28
+ else
29
+ @attached_as.to_s
30
+ end
31
+ end
32
+ end
33
+
34
+ class ModelHooks
35
+ def initialize(model)
36
+ @model = model
37
+ end
38
+
39
+ def delete!
40
+ attached_files.each do |column, versions|
41
+ (versions + [nil]).each { |version| @model.send(column, version).delete if @model.send(column, version).exists? }
42
+ end
43
+ end
44
+
45
+ def save!
46
+ attached_files.each do |column, versions|
47
+ base_file_changed = @model.send(column).changed?
48
+ original_content = @model.send(column).source_data if base_file_changed
49
+
50
+ versions.each do |version|
51
+ if @model.send(column, version).changed?
52
+ upload_file(column, version)
53
+ elsif base_file_changed
54
+ @model.send(column, version).assign(StringSource.new(original_content, default_version_filename(column, version)))
55
+ upload_file(column, version)
56
+ end
57
+ end
58
+
59
+ upload_file(column, nil) if base_file_changed
60
+ end
61
+ end
62
+
63
+ def validate!
64
+ validations.each do |column, method_or_blocks|
65
+ (attached_files[column] + [nil]).each do |version|
66
+ if @model.send(column, version).changed?
67
+ method_or_blocks.each { |method_or_block| run_validation(column, version, method_or_block) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def default_version_filename(column, version)
74
+ filename = @model.send(column).filename_to_be_assigned
75
+ "#{::File.basename(filename, ".*")}_#{version}#{::File.extname(filename)}"
76
+ end
77
+
78
+ def upload_file(column, version)
79
+ name = ColumnNamer.new(column, version).name
80
+ Config.storage.delete(@model.read_attribute(name)) if @model.read_attribute(name)
81
+ new_path = @model.send(column, version).write
82
+ @model.update_column(name, new_path)
83
+ end
84
+
85
+ def attached_files
86
+ @model.class.attached_files || {}
87
+ end
88
+
89
+ def run_validation(column, version, method_or_block)
90
+ data = @model.send(column, version).source_data
91
+ filename = @model.send(column, version).filename_to_be_assigned
92
+ opts = {attached_as: column, version: version}
93
+
94
+ if method_or_block.respond_to?(:call)
95
+ if method_or_block.arity == 2
96
+ @model.instance_exec(data, filename, &method_or_block)
97
+ else
98
+ @model.instance_exec(data, filename, opts, &method_or_block)
99
+ end
100
+ else
101
+ if @model.method(method_or_block).arity == 2
102
+ @model.send(method_or_block, data, filename)
103
+ else
104
+ @model.send(method_or_block, data, filename, opts)
105
+ end
106
+ end
107
+ end
108
+
109
+ def validations
110
+ @model.class.__saviour_validations || {}
111
+ end
112
+ end
113
+
114
+ extend ActiveSupport::Concern
115
+
116
+ NoActiveRecordDetected = Class.new(StandardError)
117
+
118
+ included do
119
+ raise(NoActiveRecordDetected, "Error: ActiveRecord not detected in #{self}") unless self.ancestors.include?(ActiveRecord::Base)
120
+
121
+ class_attribute(:attached_files, :__saviour_validations)
122
+
123
+ after_destroy { ModelHooks.new(self).delete! }
124
+ after_save { ModelHooks.new(self).save! }
125
+ validate { ModelHooks.new(self).validate! }
126
+ end
127
+
128
+ def reload
129
+ self.class.attached_files.each do |attach_as, versions|
130
+ (versions + [nil]).each { |version| instance_variable_set("@__uploader_#{version}_#{attach_as}", nil) }
131
+ end
132
+ super
133
+ end
134
+
135
+ module ClassMethods
136
+ def attach_file(attach_as, uploader_klass)
137
+ self.attached_files ||= {}
138
+ versions = uploader_klass.versions || []
139
+
140
+ ([nil] + versions).each do |version|
141
+ column_name = ColumnNamer.new(attach_as, version).name
142
+
143
+ if self.table_exists? && !self.column_names.include?(column_name.to_s)
144
+ raise RuntimeError, "#{self} must have a database string column named '#{column_name}'"
145
+ end
146
+ end
147
+
148
+ define_method(attach_as) do |version = nil|
149
+ instance_variable_get("@__uploader_#{version}_#{attach_as}") ||
150
+ instance_variable_set("@__uploader_#{version}_#{attach_as}", ::Saviour::File.new(uploader_klass, self, attach_as, version))
151
+ end
152
+
153
+ define_method("#{attach_as}=") do |value|
154
+ send(attach_as).assign(value)
155
+ end
156
+
157
+ define_method("#{attach_as}_changed?") do
158
+ send(attach_as).changed?
159
+ end
160
+
161
+ self.attached_files[attach_as] ||= []
162
+ self.attached_files[attach_as] += versions
163
+ end
164
+
165
+ def attach_validation(attach_as, method_name = nil, &block)
166
+ self.__saviour_validations ||= Hash.new { [] }
167
+ self.__saviour_validations[attach_as] += [method_name || block]
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,81 @@
1
+ require_relative 'uploader/element'
2
+ require_relative 'uploader/store_dir_extractor'
3
+ require_relative 'uploader/processors_runner'
4
+
5
+ module Saviour
6
+ class BaseUploader
7
+ attr_reader :version_name
8
+
9
+ def initialize(opts = {})
10
+ @version_name = opts[:version]
11
+ @data = opts.fetch(:data, {})
12
+ end
13
+
14
+ def method_missing(name, *args, &block)
15
+ if @data.key?(name)
16
+ @data[name]
17
+ else
18
+ super
19
+ end
20
+ end
21
+
22
+ def respond_to?(name, *)
23
+ @data.key?(name) || super
24
+ end
25
+
26
+ def write(contents, filename)
27
+ store_dir = Uploader::StoreDirExtractor.new(self).store_dir
28
+ raise RuntimeError, "Please use `store_dir` before trying to write" unless store_dir
29
+
30
+ contents, filename = Uploader::ProcessorsRunner.new(self, @version_name).run!(contents, filename) if Config.processing_enabled
31
+
32
+ path = ::File.join(store_dir, filename)
33
+ Config.storage.write(contents, path)
34
+ path
35
+ end
36
+
37
+ class << self
38
+ def store_dirs
39
+ @store_dirs ||= []
40
+ end
41
+
42
+ def processors
43
+ @processors ||= []
44
+ end
45
+
46
+ def versions
47
+ @versions ||= []
48
+ end
49
+
50
+ def process(name = nil, opts = {}, type = :memory, &block)
51
+ element = Uploader::Element.new(@current_version, name || block)
52
+
53
+ if block_given?
54
+ processors.push({element: element, type: type})
55
+ else
56
+ processors.push({element: element, type: type, opts: opts})
57
+ end
58
+ end
59
+
60
+ def process_with_file(name = nil, opts = {}, &block)
61
+ process(name, opts, :file, &block)
62
+ end
63
+
64
+
65
+ def store_dir(name = nil, &block)
66
+ element = Uploader::Element.new(@current_version, name || block)
67
+ store_dirs.push(element)
68
+ end
69
+
70
+ def version(name, &block)
71
+ versions.push(name)
72
+
73
+ if block
74
+ @current_version = name
75
+ instance_eval(&block)
76
+ @current_version = nil
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,13 @@
1
+ module Saviour
2
+ module Config
3
+ extend self
4
+
5
+ attr_writer :storage
6
+ def storage
7
+ @storage || raise(RuntimeError, "You need to provide a storage! Set Saviour::Config.storage = xxx")
8
+ end
9
+
10
+ attr_accessor :processing_enabled
11
+ @processing_enabled = true
12
+ end
13
+ end
@@ -0,0 +1,124 @@
1
+ require 'securerandom'
2
+
3
+ module Saviour
4
+ class File
5
+ class SourceFilenameExtractor
6
+ def initialize(source)
7
+ @source = source
8
+ end
9
+
10
+ def detected_filename
11
+ original_filename || path_filename
12
+ end
13
+
14
+ def original_filename
15
+ @source.respond_to?(:original_filename) && @source.original_filename.present? && @source.original_filename
16
+ end
17
+
18
+ def path_filename
19
+ @source.respond_to?(:path) && @source.path.present? && ::File.basename(@source.path)
20
+ end
21
+ end
22
+
23
+
24
+ def initialize(uploader_klass, model, attached_as, version = nil)
25
+ @uploader_klass, @model, @attached_as = uploader_klass, model, attached_as
26
+ @version = version
27
+
28
+ @persisted = !!persisted_path
29
+ @source_was = @source = nil
30
+ end
31
+
32
+ def exists?
33
+ persisted? && Config.storage.exists?(persisted_path)
34
+ end
35
+
36
+ def read
37
+ persisted? && exists? && Config.storage.read(persisted_path)
38
+ end
39
+
40
+ def delete
41
+ persisted? && exists? && Config.storage.delete(persisted_path)
42
+ end
43
+
44
+ def public_url
45
+ persisted? && Config.storage.public_url(persisted_path)
46
+ end
47
+
48
+ alias_method :url, :public_url
49
+
50
+ def assign(object)
51
+ raise(RuntimeError, "must respond to `read`") if object && !object.respond_to?(:read)
52
+
53
+ @source_data = nil
54
+ @source = object
55
+ @persisted = !object
56
+
57
+ object
58
+ end
59
+
60
+ def persisted?
61
+ @persisted
62
+ end
63
+
64
+ def changed?
65
+ @source_was != @source
66
+ end
67
+
68
+ def filename
69
+ persisted? && ::File.basename(persisted_path)
70
+ end
71
+
72
+ def with_copy
73
+ raise "must be persisted" unless persisted?
74
+
75
+ Tempfile.open([::File.basename(filename, ".*"), ::File.extname(filename)]) do |file|
76
+ begin
77
+ file.binmode
78
+ file.write(read)
79
+ file.flush
80
+ file.rewind
81
+
82
+ yield(file)
83
+ ensure
84
+ file.close
85
+ file.delete
86
+ end
87
+ end
88
+ end
89
+
90
+ def filename_to_be_assigned
91
+ changed? ? (SourceFilenameExtractor.new(@source).detected_filename || SecureRandom.hex) : nil
92
+ end
93
+
94
+ def write
95
+ raise(RuntimeError, "You must provide a source to read from first") unless @source
96
+
97
+ path = uploader.write(source_data, filename_to_be_assigned)
98
+ @source_was = @source
99
+ @persisted = true
100
+ path
101
+ end
102
+
103
+ def source_data
104
+ @source_data ||= @source.read
105
+ end
106
+
107
+ def blank?
108
+ !@source && !persisted?
109
+ end
110
+
111
+
112
+ private
113
+
114
+ def uploader
115
+ @uploader ||= @uploader_klass.new(version: @version, data: {model: @model, attached_as: @attached_as})
116
+ end
117
+
118
+ def persisted_path
119
+ if @model.persisted? || @model.destroyed?
120
+ @model.read_attribute(ColumnNamer.new(@attached_as, @version).name)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,72 @@
1
+ require 'fileutils'
2
+
3
+ module Saviour
4
+ class LocalStorage
5
+ def initialize(opts = {})
6
+ @local_prefix = opts[:local_prefix]
7
+ @public_url_prefix = opts[:public_url_prefix]
8
+ @overwrite_protection = opts.fetch(:overwrite_protection, true)
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
+ dir = ::File.dirname(real_path(path))
15
+ FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
16
+
17
+ ::File.open(real_path(path), "w") do |f|
18
+ f.binmode
19
+ f.write(contents)
20
+ end
21
+ end
22
+
23
+ def read(path)
24
+ assert_exists(path)
25
+ ::File.open(real_path(path)).read
26
+ end
27
+
28
+ def delete(path)
29
+ assert_exists(path)
30
+ ::File.delete(real_path(path))
31
+ ensure_removed_empty_dir(path)
32
+ end
33
+
34
+ def exists?(path)
35
+ ::File.file?(real_path(path))
36
+ end
37
+
38
+ def public_url(path)
39
+ raise(RuntimeError, "You must provide a `public_url_prefix`") unless public_url_prefix
40
+ ::File.join(public_url_prefix, path)
41
+ end
42
+
43
+
44
+ private
45
+
46
+ def public_url_prefix
47
+ if @public_url_prefix.respond_to?(:call)
48
+ @public_url_prefix.call
49
+ else
50
+ @public_url_prefix
51
+ end
52
+ end
53
+
54
+ def real_path(path)
55
+ @local_prefix ? ::File.join(@local_prefix, path) : path
56
+ end
57
+
58
+ def assert_exists(path)
59
+ raise(RuntimeError, "File does not exists: #{path}") unless ::File.file?(real_path(path))
60
+ end
61
+
62
+ def ensure_removed_empty_dir(path)
63
+ basedir = ::File.dirname(path)
64
+ return if basedir == "."
65
+
66
+ while basedir != "/" && Dir.entries(real_path(basedir)) == [".", ".."]
67
+ Dir.rmdir(real_path(basedir))
68
+ basedir = ::File.dirname(basedir)
69
+ end
70
+ end
71
+ end
72
+ end