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