ungulate 0.1.4 → 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. data/VERSION +1 -1
  2. data/bin/ungulate_server.rb +22 -7
  3. data/features/image_resize.feature +2 -12
  4. data/features/notification_url.feature +14 -0
  5. data/features/resize_then_composite.feature +2 -1
  6. data/features/step_definitions/bucket_steps.rb +4 -0
  7. data/features/step_definitions/command_steps.rb +10 -1
  8. data/features/step_definitions/notification_steps.rb +23 -0
  9. data/features/step_definitions/queue_steps.rb +7 -21
  10. data/features/step_definitions/version_steps.rb +2 -2
  11. data/features/{support.rb → support/config.rb} +4 -0
  12. data/features/support/helpers.rb +21 -0
  13. data/features/support/put_server.rb +22 -0
  14. data/lib/ungulate.rb +64 -0
  15. data/lib/ungulate/blob_processor.rb +34 -0
  16. data/lib/ungulate/curl_http.rb +25 -0
  17. data/lib/ungulate/file_upload.rb +22 -16
  18. data/lib/ungulate/job.rb +23 -143
  19. data/lib/ungulate/rmagick_version_creator.rb +77 -0
  20. data/lib/ungulate/s3_storage.rb +41 -0
  21. data/lib/ungulate/server.rb +27 -7
  22. data/lib/ungulate/sqs_message_queue.rb +38 -0
  23. data/spec/fixtures/chuckle.jpg +0 -0
  24. data/spec/fixtures/chuckle.png +0 -0
  25. data/spec/fixtures/chuckle_converted.png +0 -0
  26. data/spec/fixtures/chuckle_converted_badly.png +0 -0
  27. data/spec/fixtures/chuckle_thumbnail.png +0 -0
  28. data/spec/fixtures/watermark.png +0 -0
  29. data/spec/lib/ungulate/blob_processor_spec.rb +68 -0
  30. data/spec/lib/ungulate/curl_http_spec.rb +20 -0
  31. data/spec/{ungulate → lib/ungulate}/file_upload_spec.rb +10 -27
  32. data/spec/lib/ungulate/job_spec.rb +120 -0
  33. data/spec/lib/ungulate/rmagick_version_creator_spec.rb +97 -0
  34. data/spec/lib/ungulate/s3_storage_spec.rb +74 -0
  35. data/spec/lib/ungulate/server_spec.rb +44 -0
  36. data/spec/lib/ungulate/sqs_message_queue_spec.rb +28 -0
  37. data/spec/{ungulate → lib/ungulate}/view_helpers_spec.rb +1 -1
  38. data/spec/lib/ungulate_spec.rb +24 -0
  39. data/spec/spec_helper.rb +73 -0
  40. metadata +67 -25
  41. data/spec/ungulate/job_spec.rb +0 -386
  42. data/spec/ungulate/server_spec.rb +0 -42
data/lib/ungulate/job.rb CHANGED
@@ -1,162 +1,42 @@
1
- require 'rubygems'
2
- require 'right_aws'
3
- require 'RMagick'
4
- require 'mime/types'
5
1
  require 'yaml'
6
- require 'active_support/core_ext'
7
- require 'curb'
8
2
 
9
3
  module Ungulate
10
4
  class Job
11
- attr_accessor :bucket, :key, :notification_url, :queue, :versions
12
-
13
- @@blobs_from_urls = {}
14
- cattr_accessor :blobs_from_urls
15
-
16
- def self.s3
17
- @s3 ||=
18
- RightAws::S3.new(ENV['AMAZON_ACCESS_KEY_ID'],
19
- ENV['AMAZON_SECRET_ACCESS_KEY'])
5
+ def initialize(options = {})
6
+ @logger = options[:logger] || ::Logger.new($stdout)
7
+ @blob_processor = options[:blob_processor]
8
+ @storage = options[:storage]
9
+ @http = options[:http]
20
10
  end
21
11
 
22
- def self.sqs
23
- @sqs ||=
24
- RightAws::SqsGen2.new(ENV['AMAZON_ACCESS_KEY_ID'],
25
- ENV['AMAZON_SECRET_ACCESS_KEY'])
26
- end
12
+ def process(encoded_job)
13
+ @attributes = YAML.load(encoded_job)
14
+ bucket = @storage.bucket(@attributes[:bucket], :listener => self)
15
+ versions = @attributes[:versions]
16
+ blob = bucket.retrieve(@attributes[:key])
27
17
 
28
- def self.pop(queue_name)
29
- job = new
30
- job.queue = sqs.queue queue_name
31
- message = job.queue.pop
32
- attributes = YAML.load message.to_s
33
- job.attributes = attributes if attributes
34
- job
18
+ @blob_processor.process(
19
+ :blob => blob, :versions => versions,
20
+ :bucket => bucket, :original_key => @attributes[:key]
21
+ )
35
22
  end
36
23
 
37
- def initialize
38
- @logger = Ungulate::Server.logger
39
- self.versions = []
40
- end
24
+ def storage_complete(version)
25
+ stored_versions << version
41
26
 
42
- def attributes=(options)
43
- self.bucket = Job.s3.bucket(options[:bucket])
44
- self.key = options[:key]
45
- self.notification_url = options[:notification_url]
46
- self.versions = options[:versions]
47
- end
48
-
49
- def processed_versions
50
- @processed_versions ||=
51
- versions.map do |name, instruction|
52
- [name, processed_image(source_image, instruction)]
53
- end
54
- end
55
-
56
- def blob_from_url(url)
57
- Job.blobs_from_urls[url] ||=
58
- begin
59
- @logger.info "Grabbing blob from URL #{url}"
60
- Curl::Easy.http_get(url).body_str
61
- end
62
- end
63
-
64
- def magick_image_from_url(url)
65
- Magick::Image.from_blob(blob_from_url(url)).first
66
- end
67
-
68
- def instruction_args(args)
69
- args.map do |arg|
70
- if arg.is_a?(Symbol)
71
- "Magick::#{arg.to_s.classify}".constantize
72
- elsif arg.respond_to?(:match) && arg.match(/^http/)
73
- magick_image_from_url(arg)
74
- else
75
- arg
76
- end
77
- end
78
- end
79
-
80
- def image_from_instruction(original, instruction)
81
- method, *args = instruction
82
- send_args = instruction_args(args)
83
-
84
- @logger.info "Performing #{method} with #{args.join(', ')}"
85
- original.send(method, *send_args).tap do |new_image|
86
- original.destroy!
87
- send_args.select {|arg| arg.is_a?(Magick::Image)}.each(&:destroy!)
27
+ if @attributes[:notification_url] && stored_versions == versions_to_process
28
+ @http.put @attributes[:notification_url]
88
29
  end
89
30
  end
90
31
 
91
- def image_from_instruction_chain(original, chain)
92
- if chain.one?
93
- image_from_instruction(original, chain.first)
94
- else
95
- image_from_instruction_chain(
96
- image_from_instruction(original, chain.shift),
97
- chain
98
- )
99
- end
100
- end
101
-
102
- def processed_image(original, instruction)
103
- if instruction.first.respond_to?(:entries)
104
- image_from_instruction_chain(original, instruction)
105
- else
106
- image_from_instruction(original, instruction)
107
- end
108
- end
109
-
110
- def source_image
111
- Magick::Image.from_blob(source).first
112
- end
113
-
114
- def source
115
- if @source
116
- @source
117
- else
118
- @logger.info "Grabbing source image #{key}"
119
- @source = bucket.get key
120
- end
121
- end
122
-
123
- def process
124
- return false if processed_versions.empty?
125
-
126
- processed_versions.each do |version, image|
127
- version_key = version_key version
128
- @logger.info "Storing #{version} @ #{version_key}"
129
- bucket.put(
130
- version_key,
131
- image.to_blob,
132
- {},
133
- 'public-read',
134
- {
135
- 'Content-Type' => MIME::Types.type_for(image.format).to_s,
136
- # expire in about one month: refactor to grab from job description
137
- 'Cache-Control' => 'max-age=2629743',
138
- }
139
- )
140
- image.destroy!
141
- end
142
-
143
- send_notification
144
-
145
- true
146
- end
147
-
148
- def send_notification
149
- return false if notification_url.blank?
32
+ protected
150
33
 
151
- @logger.info "Sending notification to #{notification_url}"
152
- Curl::Easy.http_put(notification_url, '')
34
+ def stored_versions
35
+ @stored_versions ||= Set.new
153
36
  end
154
37
 
155
- def version_key(version)
156
- dirname = File.dirname(key)
157
- extname = File.extname(key)
158
- basename = File.basename(key, extname)
159
- "#{dirname}/#{basename}_#{version}#{extname}".sub(/^\.\//, '')
38
+ def versions_to_process
39
+ @versions_to_process ||= Set.new @attributes[:versions].keys
160
40
  end
161
41
  end
162
42
  end
@@ -0,0 +1,77 @@
1
+ require 'rmagick'
2
+ require 'active_support/core_ext/string'
3
+ require 'mime/types'
4
+
5
+ module Ungulate
6
+ class RmagickVersionCreator
7
+ def initialize(options = {})
8
+ @logger = options[:logger] || ::Logger.new($stdout)
9
+ @http = options[:http]
10
+ end
11
+
12
+ def create(blob, instructions)
13
+ image = processed_image(magick_image_from_blob(blob), instructions)
14
+ {
15
+ :blob => image.to_blob,
16
+ :content_type => MIME::Types.type_for(image.format).to_s
17
+ }
18
+ end
19
+
20
+ protected
21
+
22
+ def blob_from_url(url)
23
+ @blobs_from_urls ||= {}
24
+ @blobs_from_urls[url] ||= @http.get_body(url)
25
+ end
26
+
27
+ def magick_image_from_url(url)
28
+ Magick::Image.from_blob(blob_from_url(url)).first
29
+ end
30
+
31
+ def instruction_args(args)
32
+ args.map do |arg|
33
+ if arg.is_a?(Symbol)
34
+ "Magick::#{arg.to_s.classify}".constantize
35
+ elsif arg.respond_to?(:match) && arg.match(/^http/)
36
+ magick_image_from_url(arg)
37
+ else
38
+ arg
39
+ end
40
+ end
41
+ end
42
+
43
+ def image_from_instruction(original, instruction)
44
+ method, *args = instruction
45
+ send_args = instruction_args(args)
46
+
47
+ @logger.info "Performing #{method} with #{args.join(', ')}"
48
+ original.send(method, *send_args).tap do |new_image|
49
+ original.destroy!
50
+ send_args.select {|arg| arg.is_a?(Magick::Image)}.each(&:destroy!)
51
+ end
52
+ end
53
+
54
+ def image_from_instruction_chain(original, chain)
55
+ if chain.one?
56
+ image_from_instruction(original, chain.first)
57
+ else
58
+ image_from_instruction_chain(
59
+ image_from_instruction(original, chain.shift),
60
+ chain
61
+ )
62
+ end
63
+ end
64
+
65
+ def processed_image(original, instruction)
66
+ if instruction.first.respond_to?(:entries)
67
+ image_from_instruction_chain(original, instruction)
68
+ else
69
+ image_from_instruction(original, instruction)
70
+ end
71
+ end
72
+
73
+ def magick_image_from_blob(blob)
74
+ Magick::Image.from_blob(blob).first
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,41 @@
1
+ module Ungulate
2
+ class S3Storage
3
+ def initialize(options)
4
+ @s3 = RightAws::S3.new *options.values_at(:access_key_id, :secret_access_key)
5
+ end
6
+
7
+ def bucket(name, options = {})
8
+ S3Bucket.new(@s3, name, options)
9
+ end
10
+ end
11
+
12
+ class S3Bucket
13
+ def initialize(s3, name, options = {})
14
+ @bucket = s3.bucket(name)
15
+ @listener = options[:listener]
16
+ @logger = options[:logger] || ::Logger.new($stdout)
17
+ end
18
+
19
+ def store(key, value, options = {})
20
+ @logger.info "Storing #{key} with size #{value.size}, content-type #{options[:content_type]}"
21
+
22
+ @bucket.put(key, value, {}, 'public-read', {
23
+ 'Content-Type' => options[:content_type],
24
+ 'Cache-Control' => 'max-age=2629743'
25
+ })
26
+
27
+ if @listener && options[:version]
28
+ @listener.storage_complete(options[:version])
29
+ end
30
+ end
31
+
32
+ def retrieve(key)
33
+ @logger.info "Retrieving #{key}"
34
+ @bucket.get(key)
35
+ end
36
+
37
+ def clear
38
+ @bucket.clear
39
+ end
40
+ end
41
+ end
@@ -1,15 +1,35 @@
1
1
  require 'logger'
2
- require 'ungulate/job'
3
2
 
4
3
  module Ungulate
5
- module Server
6
- def self.logger
7
- @logger ||= ::Logger.new STDOUT
4
+ class Server
5
+ def initialize(options = {})
6
+ @logger = options[:logger] || ::Logger.new($stdout)
7
+ @job_processor = options[:job_processor]
8
+ @queue = options[:queue]
8
9
  end
9
10
 
10
- def self.run(queue_name)
11
- logger.info "Checking for job on #{queue_name}"
12
- Job.pop(queue_name).process
11
+ class << self
12
+ def config
13
+ Ungulate.configuration
14
+ end
15
+
16
+ def run
17
+ new(
18
+ :job_processor => config.job_processor.call,
19
+ :queue => config.queue.call
20
+ ).run
21
+ end
22
+ end
23
+
24
+ def run
25
+ @logger.info "Checking for job on #{@queue.name}"
26
+ message = @queue.receive
27
+
28
+ if message
29
+ success = @job_processor.process(message.to_s)
30
+ message.delete
31
+ success
32
+ end
13
33
  end
14
34
  end
15
35
  end
@@ -0,0 +1,38 @@
1
+ require 'right_aws'
2
+
3
+ module Ungulate
4
+ class SqsMessageQueue
5
+ def initialize(name, options)
6
+ if name.blank?
7
+ raise Ungulate::MissingConfiguration,
8
+ "queue_name must be set in config"
9
+ end
10
+
11
+ sqs = RightAws::SqsGen2.new(
12
+ options[:access_key_id], options[:secret_access_key],
13
+ :server => options[:server]
14
+ )
15
+ @queue = sqs.queue name
16
+ end
17
+
18
+ def name
19
+ @queue.name
20
+ end
21
+
22
+ def clear
23
+ @queue.clear
24
+ end
25
+
26
+ def push(message)
27
+ @queue.push(message)
28
+ end
29
+
30
+ def receive
31
+ @queue.receive
32
+ end
33
+
34
+ def size
35
+ @queue.size
36
+ end
37
+ end
38
+ end
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,68 @@
1
+ require 'spec_helper'
2
+ require 'ungulate/blob_processor'
3
+
4
+ module Ungulate
5
+ describe BlobProcessor do
6
+ subject { BlobProcessor.new(:version_creator => creator) }
7
+
8
+ let(:creator) { double 'version creator' }
9
+ let(:bucket) { double 'bucket' }
10
+
11
+ it "processes a blob into several versions and sends them to storage" do
12
+ blob = 'asdf'
13
+
14
+ large = [
15
+ [ :resize_to_fill, 400, 300 ],
16
+ [ :composite, 'http://some.image/url.jpg', :center_gravity, :soft_light_composite_op ],
17
+ ]
18
+ medium = [ :resize_to_fit, 300, 200 ]
19
+ thumbnail = [ :resize_to_fit, 40, 20 ]
20
+
21
+ versions = {
22
+ :large => large,
23
+ :medium => medium,
24
+ :thumbnail => thumbnail
25
+ }
26
+
27
+
28
+ creator.should_receive(:create).with(blob, large).
29
+ and_return(:blob => 'largeblob', :content_type => 'image/png')
30
+
31
+ creator.should_receive(:create).with(blob, medium).
32
+ and_return(:blob => 'mediumblob', :content_type => 'image/jpeg')
33
+
34
+ creator.should_receive(:create).with(blob, thumbnail).
35
+ and_return(:blob => 'thumbnailblob', :content_type => 'application/xml')
36
+
37
+ bucket.should_receive(:store).
38
+ with('some/file_large.jpg', 'largeblob', :version => :large,
39
+ :content_type => 'image/png')
40
+
41
+ bucket.should_receive(:store).
42
+ with('some/file_medium.jpg', 'mediumblob', :version => :medium,
43
+ :content_type => 'image/jpeg')
44
+
45
+ bucket.should_receive(:store).
46
+ with('some/file_thumbnail.jpg', 'thumbnailblob', :version => :thumbnail,
47
+ :content_type => 'application/xml')
48
+
49
+ subject.process(
50
+ :blob => blob, :versions => versions,
51
+ :original_key => 'some/file.jpg', :bucket => bucket
52
+ )
53
+ end
54
+
55
+ context "when key has no leading path" do
56
+ it "converts the key properly" do
57
+ creator.stub(:create).and_return({})
58
+
59
+ bucket.should_receive(:store).with('file_large.jpg', anything, anything)
60
+
61
+ subject.process(
62
+ :blob => 'asdf', :versions => { :large => [] },
63
+ :original_key => 'file.jpg', :bucket => bucket
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end