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
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ungulate/curl_http'
|
3
|
+
|
4
|
+
module Ungulate
|
5
|
+
describe CurlHttp do
|
6
|
+
subject do
|
7
|
+
CurlHttp.new(:logger => ::Logger.new(nil))
|
8
|
+
end
|
9
|
+
|
10
|
+
it "can return the body of a resource from https" do
|
11
|
+
subject.get_body('https://dmxno528jhfy0.cloudfront.net/superhug-watermark.png').
|
12
|
+
should == fixture('watermark.png')
|
13
|
+
end
|
14
|
+
|
15
|
+
it "can PUT to a HTTP URL" do
|
16
|
+
response = subject.put('https://dmxno528jhfy0.cloudfront.net/superhug-watermark.png')
|
17
|
+
response.code.should == 403
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -1,5 +1,6 @@
|
|
1
|
-
require
|
1
|
+
require 'spec_helper'
|
2
2
|
require 'ungulate/file_upload'
|
3
|
+
require 'active_support/core_ext/numeric/time'
|
3
4
|
|
4
5
|
module Ungulate
|
5
6
|
describe FileUpload do
|
@@ -11,14 +12,15 @@ module Ungulate
|
|
11
12
|
let(:queue_name) { 'some-queue-name' }
|
12
13
|
|
13
14
|
before do
|
14
|
-
|
15
|
-
|
16
|
-
|
15
|
+
Ungulate.configure do |config|
|
16
|
+
config.access_key_id = access_key_id
|
17
|
+
config.secret_access_key = secret_access_key
|
18
|
+
config.queue_name = 'not-used'
|
19
|
+
config.queue = lambda { q }
|
20
|
+
end
|
17
21
|
end
|
18
22
|
|
19
23
|
its(:access_key_id) { should == access_key_id }
|
20
|
-
its(:queue_name) { should == queue_name }
|
21
|
-
its(:secret_access_key) { should == secret_access_key }
|
22
24
|
|
23
25
|
context "policy set directly" do
|
24
26
|
let(:policy) do
|
@@ -94,12 +96,11 @@ module Ungulate
|
|
94
96
|
end
|
95
97
|
|
96
98
|
describe "enqueue" do
|
97
|
-
let(:q) {
|
99
|
+
let(:q) { double 'queue' }
|
98
100
|
let(:job_hash) { stub('Hash', :to_yaml => :some_yaml) }
|
99
|
-
before { Ungulate::FileUpload.stub(:queue).and_return(q) }
|
100
101
|
|
101
102
|
it "queues the yamlised version of the passed job hash" do
|
102
|
-
q.should_receive(:
|
103
|
+
q.should_receive(:push).with(:some_yaml)
|
103
104
|
Ungulate::FileUpload.enqueue(job_hash)
|
104
105
|
end
|
105
106
|
end
|
@@ -153,24 +154,6 @@ module Ungulate
|
|
153
154
|
end
|
154
155
|
end
|
155
156
|
|
156
|
-
describe "queue" do
|
157
|
-
let(:sqs) do
|
158
|
-
sqs = stub 'SQS'
|
159
|
-
sqs.stub(:queue).with(queue_name).and_return(:queue_instance)
|
160
|
-
sqs
|
161
|
-
end
|
162
|
-
|
163
|
-
subject { Ungulate::FileUpload.queue }
|
164
|
-
|
165
|
-
before do
|
166
|
-
RightAws::SqsGen2.stub(:new).
|
167
|
-
with(access_key_id, secret_access_key).
|
168
|
-
and_return(sqs)
|
169
|
-
end
|
170
|
-
|
171
|
-
it { should == :queue_instance }
|
172
|
-
end
|
173
|
-
|
174
157
|
describe "signature" do
|
175
158
|
let(:sha1) { stub 'SHA1' }
|
176
159
|
|
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ungulate/job'
|
3
|
+
|
4
|
+
module Ungulate
|
5
|
+
describe Job do
|
6
|
+
subject do
|
7
|
+
Job.new(:blob_processor => blob_processor, :storage => storage,
|
8
|
+
:http => http, :logger => ::Logger.new(nil))
|
9
|
+
end
|
10
|
+
|
11
|
+
let(:http) { double 'http' }
|
12
|
+
let(:blob_processor) { double 'blob processor' }
|
13
|
+
let(:storage) { double 'storage', :bucket => bucket }
|
14
|
+
let(:bucket) { double 'bucket', :retrieve => nil }
|
15
|
+
let(:versions) do
|
16
|
+
{
|
17
|
+
:large => [ :resize_to_fill, 400, 300 ],
|
18
|
+
:medium => [ :resize_to_fit, 300, 200 ],
|
19
|
+
:thumbnail => [ :resize_to_fit, 40, 20 ]
|
20
|
+
}
|
21
|
+
end
|
22
|
+
let(:job_description) do
|
23
|
+
{
|
24
|
+
:bucket => 'some-bucket',
|
25
|
+
:key => 'original-key.jpg',
|
26
|
+
:versions => versions
|
27
|
+
}
|
28
|
+
end
|
29
|
+
let(:job_encoded) { job_description.to_yaml }
|
30
|
+
|
31
|
+
it "gets an original blob and sends it to be processed" do
|
32
|
+
blob = double 'blob'
|
33
|
+
|
34
|
+
storage.should_receive(:bucket).with('some-bucket', :listener => subject).
|
35
|
+
and_return(bucket)
|
36
|
+
bucket.should_receive(:retrieve).with('original-key.jpg').and_return(blob)
|
37
|
+
|
38
|
+
blob_processor.should_receive(:process).
|
39
|
+
with(:blob => blob, :versions => versions, :bucket => bucket,
|
40
|
+
:original_key => 'original-key.jpg')
|
41
|
+
|
42
|
+
subject.process(job_encoded)
|
43
|
+
end
|
44
|
+
|
45
|
+
context "with no notification URL" do
|
46
|
+
it "accepts storage complete messages, but does nothing" do
|
47
|
+
storage.stub(:get)
|
48
|
+
blob_processor.stub(:process)
|
49
|
+
subject.process(job_encoded)
|
50
|
+
subject.storage_complete(:large)
|
51
|
+
subject.storage_complete(:medium)
|
52
|
+
|
53
|
+
http.should_not_receive(:put)
|
54
|
+
subject.storage_complete(:thumbnail)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
context "when notification URL set" do
|
59
|
+
let(:job_description) do
|
60
|
+
{
|
61
|
+
:bucket => 'some-bucket',
|
62
|
+
:key => 'original-key.jpg',
|
63
|
+
:notification_url => 'http://some.url',
|
64
|
+
:versions => versions
|
65
|
+
}
|
66
|
+
end
|
67
|
+
|
68
|
+
before do
|
69
|
+
storage.stub(:get)
|
70
|
+
blob_processor.stub(:process)
|
71
|
+
subject.process(job_encoded)
|
72
|
+
end
|
73
|
+
|
74
|
+
context "with only one version" do
|
75
|
+
let(:versions) { { :large => [ :resize_to_fill, 400, 300 ] } }
|
76
|
+
|
77
|
+
it "PUTs to the notification URL when only version stored" do
|
78
|
+
http.should_receive(:put).with('http://some.url')
|
79
|
+
subject.storage_complete(:large)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context "with three versions" do
|
84
|
+
let(:versions) do
|
85
|
+
{
|
86
|
+
:large => [ :resize_to_fill, 400, 300 ],
|
87
|
+
:medium => [ :resize_to_fit, 300, 200 ],
|
88
|
+
:thumbnail => [ :resize_to_fit, 40, 20 ]
|
89
|
+
}
|
90
|
+
end
|
91
|
+
|
92
|
+
it "PUTs to the notification URL when third version stored" do
|
93
|
+
subject.storage_complete(:large)
|
94
|
+
subject.storage_complete(:medium)
|
95
|
+
|
96
|
+
http.should_receive(:put).with('http://some.url')
|
97
|
+
subject.storage_complete(:thumbnail)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
context "with a different URL" do
|
102
|
+
let(:job_description) do
|
103
|
+
{
|
104
|
+
:bucket => 'some-bucket',
|
105
|
+
:key => 'original-key.jpg',
|
106
|
+
:notification_url => 'http://some.other.url',
|
107
|
+
:versions => versions
|
108
|
+
}
|
109
|
+
end
|
110
|
+
|
111
|
+
it "uses the other URL" do
|
112
|
+
subject.storage_complete(:large)
|
113
|
+
subject.storage_complete(:medium)
|
114
|
+
http.should_receive(:put).with('http://some.other.url')
|
115
|
+
subject.storage_complete(:thumbnail)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ungulate/rmagick_version_creator'
|
3
|
+
|
4
|
+
module Ungulate
|
5
|
+
describe RmagickVersionCreator do
|
6
|
+
subject do
|
7
|
+
RmagickVersionCreator.new(:logger => ::Logger.new(nil),
|
8
|
+
:http => http)
|
9
|
+
end
|
10
|
+
|
11
|
+
let(:http) { double 'http' }
|
12
|
+
|
13
|
+
shared_examples_for "an image converter" do
|
14
|
+
it "creates a matching blob" do
|
15
|
+
new_blob = subject.create(original, instructions)[:blob]
|
16
|
+
|
17
|
+
expected_image = Magick::Image.from_blob(converted).first
|
18
|
+
got_image = Magick::Image.from_blob(new_blob).first
|
19
|
+
|
20
|
+
expected_image.difference(got_image)
|
21
|
+
|
22
|
+
puts "good image:"
|
23
|
+
puts "mean per pixel: #{expected_image.mean_error_per_pixel}"
|
24
|
+
puts "normalized mean: #{expected_image.normalized_mean_error}"
|
25
|
+
puts "normalized max: #{expected_image.normalized_maximum_error}"
|
26
|
+
|
27
|
+
expected_image.mean_error_per_pixel.round.should be_zero
|
28
|
+
expected_image.normalized_maximum_error.round.should be_zero
|
29
|
+
|
30
|
+
expected_image.destroy!
|
31
|
+
got_image.destroy!
|
32
|
+
end
|
33
|
+
|
34
|
+
context "for a png" do
|
35
|
+
it "includes image/png as the content-type in the return hash" do
|
36
|
+
subject.create(original, instructions)[:content_type].
|
37
|
+
should == 'image/png'
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
context "for a jpeg" do
|
42
|
+
let(:original) { fixture 'chuckle.jpg' }
|
43
|
+
|
44
|
+
it "includes image/jpeg as the content-type in the return hash" do
|
45
|
+
subject.create(original, instructions)[:content_type].
|
46
|
+
should == 'image/jpeg'
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context "resize to fill only" do
|
52
|
+
let(:original) { fixture 'chuckle.png' }
|
53
|
+
let(:converted) { fixture 'chuckle_thumbnail.png' }
|
54
|
+
let(:instructions) { [ :resize_to_fill, 80, 80 ] }
|
55
|
+
it_behaves_like "an image converter"
|
56
|
+
end
|
57
|
+
|
58
|
+
context "resize to fit and then composite" do
|
59
|
+
let(:url) { "https://some/watermark.png" }
|
60
|
+
let(:original) { fixture 'chuckle.png' }
|
61
|
+
let(:converted) { fixture 'chuckle_converted.png' }
|
62
|
+
let(:bad) { fixture 'chuckle_converted_badly.png' }
|
63
|
+
|
64
|
+
let(:instructions) do
|
65
|
+
[
|
66
|
+
[ :resize_to_fit, 628, 464 ],
|
67
|
+
[ :composite, url, :center_gravity, :soft_light_composite_op ]
|
68
|
+
]
|
69
|
+
end
|
70
|
+
|
71
|
+
before do
|
72
|
+
http.should_receive(:get_body).with(url).and_return fixture('watermark.png')
|
73
|
+
end
|
74
|
+
|
75
|
+
it_behaves_like "an image converter"
|
76
|
+
|
77
|
+
it "doesn't compare well with a broken image" do
|
78
|
+
new_blob = subject.create(original, instructions)[:blob]
|
79
|
+
got_image = Magick::Image.from_blob(new_blob).first
|
80
|
+
bad_image = Magick::Image.from_blob(bad).first
|
81
|
+
|
82
|
+
bad_image.difference(got_image)
|
83
|
+
|
84
|
+
puts "bad image:"
|
85
|
+
puts "mean per pixel: #{bad_image.mean_error_per_pixel}"
|
86
|
+
puts "normalized mean: #{bad_image.normalized_mean_error}"
|
87
|
+
puts "normalized max: #{bad_image.normalized_maximum_error}"
|
88
|
+
|
89
|
+
bad_image.mean_error_per_pixel.round.should > 0
|
90
|
+
bad_image.normalized_maximum_error.round.should > 0
|
91
|
+
|
92
|
+
bad_image.destroy!
|
93
|
+
got_image.destroy!
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ungulate/s3_storage'
|
3
|
+
|
4
|
+
# integration test
|
5
|
+
module Ungulate
|
6
|
+
describe S3Storage do
|
7
|
+
def new_storage
|
8
|
+
S3Storage.new(
|
9
|
+
:access_key_id => ENV['AMAZON_ACCESS_KEY_ID'],
|
10
|
+
:secret_access_key => ENV['AMAZON_SECRET_ACCESS_KEY']
|
11
|
+
)
|
12
|
+
end
|
13
|
+
|
14
|
+
subject { new_storage }
|
15
|
+
|
16
|
+
it "can store and retrieve blobs with keys" do
|
17
|
+
bucket = subject.bucket('ungulate-test')
|
18
|
+
bucket.clear
|
19
|
+
bucket.store('somekey', 'somedata')
|
20
|
+
bucket.retrieve('somekey').should == 'somedata'
|
21
|
+
|
22
|
+
bucket.store('someotherkey', 'someotherdata')
|
23
|
+
bucket.retrieve('someotherkey').should == 'someotherdata'
|
24
|
+
end
|
25
|
+
|
26
|
+
it "persists data across instances" do
|
27
|
+
bucket = subject.bucket('ungulate-test')
|
28
|
+
bucket.clear
|
29
|
+
bucket.store('somekey', 'somedata')
|
30
|
+
|
31
|
+
new_storage.bucket('ungulate-test').
|
32
|
+
retrieve('somekey').should == 'somedata'
|
33
|
+
end
|
34
|
+
|
35
|
+
it "stores publicly accessible items with a long cache expiry" do
|
36
|
+
key = 'somekey'
|
37
|
+
bucket = subject.bucket('ungulate-test')
|
38
|
+
bucket.clear
|
39
|
+
bucket.store(key, 'somedata')
|
40
|
+
|
41
|
+
response = Curl::Easy.http_get("ungulate-test.s3.amazonaws.com/#{key}")
|
42
|
+
response.header_str.should include('Cache-Control: max-age=2629743')
|
43
|
+
end
|
44
|
+
|
45
|
+
it "sets the correct content-type" do
|
46
|
+
key = 'somekey.png'
|
47
|
+
bucket = subject.bucket('ungulate-test')
|
48
|
+
bucket.clear
|
49
|
+
bucket.store(key, 'somedata', :content_type => 'image/png')
|
50
|
+
response = Curl::Easy.http_get("ungulate-test.s3.amazonaws.com/#{key}")
|
51
|
+
response.content_type.should == 'image/png'
|
52
|
+
end
|
53
|
+
|
54
|
+
context "when listener set" do
|
55
|
+
it "notifies the listener when it's done" do
|
56
|
+
listener = double 'listener'
|
57
|
+
bucket = subject.bucket('ungulate-test', :listener => listener)
|
58
|
+
|
59
|
+
listener.should_receive(:storage_complete).with(:large)
|
60
|
+
bucket.store('large', 'largedata', :version => :large)
|
61
|
+
end
|
62
|
+
|
63
|
+
context "but no version passed" do
|
64
|
+
it "does not notify the listener" do
|
65
|
+
listener = double 'listener'
|
66
|
+
bucket = subject.bucket('ungulate-test', :listener => listener)
|
67
|
+
|
68
|
+
listener.should_not_receive(:storage_complete)
|
69
|
+
bucket.store('large', 'largedata')
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ungulate/server'
|
3
|
+
|
4
|
+
module Ungulate
|
5
|
+
describe Server do
|
6
|
+
subject do
|
7
|
+
Server.new(:job_processor => processor, :queue => queue,
|
8
|
+
:logger => ::Logger.new(nil))
|
9
|
+
end
|
10
|
+
|
11
|
+
let(:processor) { double 'job processor' }
|
12
|
+
let(:queue_name) { 'queuename' }
|
13
|
+
let(:queue) { double 'queue', :name => 'Some Queue' }
|
14
|
+
let(:message) { double 'message', :to_s => '' }
|
15
|
+
|
16
|
+
it "receives a message from the provided queue and processes it with the processor" do
|
17
|
+
queue.should_receive(:receive).ordered.and_return(message)
|
18
|
+
message.should_receive(:to_s).and_return('job description')
|
19
|
+
processor.should_receive(:process).with('job description').ordered
|
20
|
+
message.should_receive(:delete).ordered
|
21
|
+
|
22
|
+
subject.run
|
23
|
+
end
|
24
|
+
|
25
|
+
it "returns truthy from run if it processed something" do
|
26
|
+
queue.stub(:receive).and_return(message)
|
27
|
+
message.stub(:delete)
|
28
|
+
processor.stub(:process).and_return(true)
|
29
|
+
subject.run.should be_true
|
30
|
+
end
|
31
|
+
|
32
|
+
it "returns whatever the processor returns" do
|
33
|
+
queue.stub(:receive).and_return(message)
|
34
|
+
message.stub(:delete)
|
35
|
+
processor.stub(:process).and_return('some message')
|
36
|
+
subject.run.should == 'some message'
|
37
|
+
end
|
38
|
+
|
39
|
+
it "returns falsey from run if it did not process anything" do
|
40
|
+
queue.stub(:receive).and_return(nil)
|
41
|
+
subject.run.should be_false
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ungulate/sqs_message_queue'
|
3
|
+
|
4
|
+
# integration test - talks to real queue
|
5
|
+
#
|
6
|
+
# to spec a new message queue, copy this spec and change the require and
|
7
|
+
# describe lines, and change new_queue to instantiate your queue class
|
8
|
+
# message queues are always passed the options below, from the environment
|
9
|
+
#
|
10
|
+
# note that you'll probably need to wrap the messages returned from your queue
|
11
|
+
# to behave like messages from SQS, i.e. they need to implement 'to_s' to convert
|
12
|
+
# them to a string, and 'delete' to delete them from the queue
|
13
|
+
module Ungulate
|
14
|
+
describe SqsMessageQueue do
|
15
|
+
def new_queue
|
16
|
+
SqsMessageQueue.new('some_test_queue',
|
17
|
+
:access_key_id => ENV['AMAZON_ACCESS_KEY_ID'],
|
18
|
+
:secret_access_key => ENV['AMAZON_SECRET_ACCESS_KEY'],
|
19
|
+
:server => ENV['QUEUE_SERVER'])
|
20
|
+
end
|
21
|
+
|
22
|
+
it_behaves_like "a message queue"
|
23
|
+
|
24
|
+
it "raises an exception if queue name not set" do
|
25
|
+
expect { SqsMessageQueue.new('', {}) }.to raise_error(MissingConfiguration)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|