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.
- data/VERSION +1 -1
- data/bin/ungulate_server.rb +22 -7
- data/features/image_resize.feature +2 -12
- data/features/notification_url.feature +14 -0
- data/features/resize_then_composite.feature +2 -1
- data/features/step_definitions/bucket_steps.rb +4 -0
- data/features/step_definitions/command_steps.rb +10 -1
- data/features/step_definitions/notification_steps.rb +23 -0
- data/features/step_definitions/queue_steps.rb +7 -21
- data/features/step_definitions/version_steps.rb +2 -2
- data/features/{support.rb → support/config.rb} +4 -0
- data/features/support/helpers.rb +21 -0
- data/features/support/put_server.rb +22 -0
- data/lib/ungulate.rb +64 -0
- data/lib/ungulate/blob_processor.rb +34 -0
- data/lib/ungulate/curl_http.rb +25 -0
- data/lib/ungulate/file_upload.rb +22 -16
- data/lib/ungulate/job.rb +23 -143
- data/lib/ungulate/rmagick_version_creator.rb +77 -0
- data/lib/ungulate/s3_storage.rb +41 -0
- data/lib/ungulate/server.rb +27 -7
- data/lib/ungulate/sqs_message_queue.rb +38 -0
- data/spec/fixtures/chuckle.jpg +0 -0
- data/spec/fixtures/chuckle.png +0 -0
- data/spec/fixtures/chuckle_converted.png +0 -0
- data/spec/fixtures/chuckle_converted_badly.png +0 -0
- data/spec/fixtures/chuckle_thumbnail.png +0 -0
- data/spec/fixtures/watermark.png +0 -0
- data/spec/lib/ungulate/blob_processor_spec.rb +68 -0
- data/spec/lib/ungulate/curl_http_spec.rb +20 -0
- data/spec/{ungulate → lib/ungulate}/file_upload_spec.rb +10 -27
- data/spec/lib/ungulate/job_spec.rb +120 -0
- data/spec/lib/ungulate/rmagick_version_creator_spec.rb +97 -0
- data/spec/lib/ungulate/s3_storage_spec.rb +74 -0
- data/spec/lib/ungulate/server_spec.rb +44 -0
- data/spec/lib/ungulate/sqs_message_queue_spec.rb +28 -0
- data/spec/{ungulate → lib/ungulate}/view_helpers_spec.rb +1 -1
- data/spec/lib/ungulate_spec.rb +24 -0
- data/spec/spec_helper.rb +73 -0
- metadata +67 -25
- data/spec/ungulate/job_spec.rb +0 -386
- 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
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
23
|
-
@
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
38
|
-
|
39
|
-
self.versions = []
|
40
|
-
end
|
24
|
+
def storage_complete(version)
|
25
|
+
stored_versions << version
|
41
26
|
|
42
|
-
|
43
|
-
|
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
|
-
|
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
|
-
|
152
|
-
|
34
|
+
def stored_versions
|
35
|
+
@stored_versions ||= Set.new
|
153
36
|
end
|
154
37
|
|
155
|
-
def
|
156
|
-
|
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
|
data/lib/ungulate/server.rb
CHANGED
@@ -1,15 +1,35 @@
|
|
1
1
|
require 'logger'
|
2
|
-
require 'ungulate/job'
|
3
2
|
|
4
3
|
module Ungulate
|
5
|
-
|
6
|
-
def
|
7
|
-
@logger
|
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
|
-
|
11
|
-
|
12
|
-
|
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
|
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
|