anisoptera 0.0.2

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.
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .DS_Store
6
+ .rvmrc
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format documentation
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in anisoptera.gemspec
4
+ gemspec
@@ -0,0 +1,116 @@
1
+ # Configurable Rack endpoint for Async image resizing.
2
+
3
+ In progress.
4
+
5
+ Borrows heavily from Dragonfly ([https://github.com/markevans/dragonfly](https://github.com/markevans/dragonfly)).
6
+
7
+ Async mode relies on Thin's async.callback env variable and EventMachine, and ImageMagick for image processing.
8
+
9
+ See [http_router](/ismasan/anisoptera/blob/master/examples/http_router.ru) example for an intro.
10
+
11
+ ## Usage
12
+
13
+ You need a rack router of some sort.
14
+
15
+ ```ruby
16
+ # image_resizer.ru
17
+
18
+ require 'http_router'
19
+ require 'anisoptera'
20
+
21
+ Anisoptera[:media].configure do |config|
22
+ # This is where your original image files are
23
+ config.base_path = './'
24
+ # In case of error, resize and serve up this image
25
+ config.error_image = './Error.gif'
26
+ # Run this block in case of error
27
+ config.on_error do |exception, params|
28
+ Airbrake.notify(
29
+ :error_class => exception.class.name,
30
+ :error_message => exception.message,
31
+ :parameters => params
32
+ )
33
+ end
34
+ end
35
+
36
+ # Create an app with defined routes
37
+
38
+ app = HttpRouter.new do
39
+
40
+ add('/media/:geometry/:color_mode/:filename').to Anisoptera[:media].endpoint {|image, params|
41
+ image.file(params[:filename]).thumb(params[:geometry])
42
+ image.greyscale if params[:color_mode] == 'grey'
43
+ image.encode('png')
44
+ }
45
+ end
46
+
47
+ # Run the app
48
+ run app
49
+ ```
50
+
51
+ Run with Thin
52
+
53
+ $ thin start -R image_resizer.ru -e production -p 5000
54
+
55
+ Now you get on-the fly image resizes
56
+
57
+ http://some.host/media/100x100/grey/logo.png
58
+
59
+ Anisoptera returns all the right HTTP headers so if you put this behind a caching proxy such as Varnish it should just work.
60
+
61
+ ## Custom headers
62
+
63
+ You can pass optional headers, or override default ones
64
+
65
+ ```ruby
66
+ Anisoptera[:media].configure do |config|
67
+ config.headers = {
68
+ 'Cache-Control' => '1234567890',
69
+ 'X-Custom' => 'Hello there'
70
+ }
71
+ end
72
+ ```
73
+
74
+ ## DoS protection
75
+
76
+ Obviously it's a bad idea to allow people to freely resize images on the fly as it might bring your servers down. You can hash the parameters in the URL with a shared key and secret, something like:
77
+
78
+ ```ruby
79
+ get('/media/:hash').to Anisoptera[:media].endpoint {|image, params|
80
+ verify_sha! params[:hash], params[:k]
81
+
82
+ args = Anisoptera::Serializer.marshal_decode(params[:hash])
83
+ image_path = args[:file_name]
84
+ image.file(image_path).thumb(args[:geometry])
85
+ image.greyscale if args[:grey]
86
+ image.encode('jpg')
87
+ }
88
+ ```
89
+
90
+ Then you request images with passing a hash of parameters encoded with the shared secret, and a public key to decode it back.
91
+
92
+ http://some.host/media/BAh7CToGZiIVMjUzNjctaGVsbWV0LmpwZzoJZ3JleUY6DHNob3BfaWRpAeA6BmciDDIwMHgyMDA?k=6a58f9458425f73
93
+
94
+ Anisoptera::Serializer's encode and decode methods can help you Base64-encode a hash of parameters. This is also good because some ImageMagick geometry strings are not valid URL components.
95
+
96
+ # LICENSE
97
+
98
+ Copyright (C) 2011 Ismael Celis
99
+
100
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
101
+ this software and associated documentation files (the "Software"), to deal in
102
+ the Software without restriction, including without limitation the rights to
103
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
104
+ of the Software, and to permit persons to whom the Software is furnished to do
105
+ so, subject to the following conditions:
106
+
107
+ The above copyright notice and this permission notice shall be included in all
108
+ copies or substantial portions of the Software.
109
+
110
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
111
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
112
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
113
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
114
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
115
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
116
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ RSpec::Core::RakeTask.new('spec')
6
+
7
+ # If you want to make this the default task
8
+ task :default => :spec
@@ -0,0 +1,32 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "anisoptera/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "anisoptera"
7
+ s.version = Anisoptera::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Ismael Celis"]
10
+ s.email = ["ismaelct@gmail.com"]
11
+ s.homepage = ""
12
+ s.summary = %q{Async Rack app for image thumbnailing}
13
+ s.description = %q{You'll need an Eventmachine server such as Thin to run this. See README.'}
14
+
15
+ s.rubyforge_project = "anisoptera"
16
+
17
+ s.add_dependency 'eventmachine', ">= 0.12.10"
18
+ s.add_dependency 'thin'
19
+ s.add_dependency 'thin_async'
20
+ s.add_dependency 'rack', ">= 1.2.2"
21
+
22
+ s.add_development_dependency "bundler", ">= 1.0.0"
23
+ s.add_development_dependency "rack-test"
24
+ s.add_development_dependency "rspec"
25
+ s.add_development_dependency "thin-async-test"
26
+ s.add_development_dependency "http_router"
27
+
28
+ s.files = `git ls-files`.split("\n")
29
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
30
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
31
+ s.require_paths = ["lib"]
32
+ end
@@ -0,0 +1,36 @@
1
+ # thin start -R http_router -p 3000
2
+ #
3
+ # http://localhost:3000/media/170x240+210+210/m/pic1.jpg
4
+ # http://localhost:3000/media/170x240/m/pic1.jpg
5
+ # http://localhost:3000/media/100x100-ne/m/pic1.jpg
6
+ # http://localhost:3000/media/100x100-c/m/pic1.jpg
7
+ # http://localhost:3000/media/100x100-c/greyscale/pic1.jpg
8
+
9
+ require 'rubygems'
10
+ # require 'bundler'
11
+ # Bundler.setup
12
+
13
+ require 'http_router'
14
+
15
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
16
+
17
+ require 'anisoptera'
18
+
19
+ Anisoptera[:media].configure do |config|
20
+ config.base_path = './'
21
+ end
22
+
23
+ # Anisoptera.prefer_async = false # uncomment this to test synchronous endpoint with any Rack server
24
+
25
+
26
+ routes = HttpRouter.new do
27
+
28
+ add('/media/:geometry/:color_mode/:filename').to Anisoptera[:media].endpoint {|image, params|
29
+ image.file(params[:filename]).thumb(params[:geometry])
30
+ image.greyscale if params[:color_mode] == 'grey'
31
+ image.encode('png')
32
+ }
33
+
34
+ end
35
+
36
+ run routes
Binary file
@@ -0,0 +1,32 @@
1
+ require 'ostruct'
2
+ require 'rack/mime'
3
+ require 'anisoptera/commander'
4
+ require 'anisoptera/serializer'
5
+ require 'anisoptera/version'
6
+ require 'anisoptera/app'
7
+ require 'anisoptera/endpoint'
8
+
9
+ module Anisoptera
10
+
11
+ HEADERS = {
12
+ 'Cache-Control' => 'public, max-age=3153600'
13
+ }.freeze
14
+
15
+ @apps = {}
16
+ @prefer_async = true
17
+
18
+ def prefer_async=(bool)
19
+ @prefer_async = bool
20
+ end
21
+
22
+ def prefer_async
23
+ !!@prefer_async
24
+ end
25
+
26
+ def [](app_name)
27
+ @apps[app_name] ||= Anisoptera::App.new
28
+ end
29
+
30
+ extend self
31
+
32
+ end
@@ -0,0 +1,29 @@
1
+ module Anisoptera
2
+
3
+ class Config < OpenStruct
4
+
5
+ def on_error(&block)
6
+ @on_error = block if block_given?
7
+ @on_error
8
+ end
9
+
10
+ end
11
+
12
+ class App
13
+
14
+ attr_reader :config
15
+
16
+ def initialize
17
+ @config = Config.new
18
+ end
19
+
20
+ def configure(&block)
21
+ block.call @config
22
+ end
23
+
24
+ def endpoint(&block)
25
+ Anisoptera::Endpoint.factory(@config, block)
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,73 @@
1
+ require 'eventmachine'
2
+ require 'thin/async'
3
+
4
+ module Anisoptera
5
+
6
+ class AsyncEndpoint
7
+
8
+ include Anisoptera::Endpoint
9
+
10
+ # This is a template async response.
11
+ AsyncResponse = [-1, {}, []].freeze
12
+
13
+ STATUSES = {
14
+ 0 => 200,
15
+ 1 => 500,
16
+ 255 => 200 # exit status represented differently when running tests in editor
17
+ }
18
+
19
+ def call(env)
20
+ response = Thin::AsyncResponse.new(env)
21
+
22
+ params = routing_params(env)
23
+
24
+ begin
25
+ job = Anisoptera::Commander.new( @config.base_path, @config.convert_command )
26
+ convert = @handler.call(job, params)
27
+ response.headers.update(update_headers(convert))
28
+
29
+ if !job.check_file
30
+ response.headers['X-Error'] = 'Image not found'
31
+ handle_error error_status(404), response, convert
32
+ else
33
+ handle_success response, convert
34
+ end
35
+ rescue => boom
36
+ response.headers['X-Error'] = boom.message
37
+ response.headers.update(update_headers)
38
+ @config.on_error.call(boom, params, env) if @config.on_error
39
+ handle_error(error_status(500), response)
40
+ end
41
+
42
+ response.finish
43
+ end
44
+
45
+ protected
46
+
47
+ def handle_success(response, convert)
48
+ EM.system( convert.command ){ |output, status|
49
+ http_status = STATUSES[status.exitstatus]
50
+ response.status = http_status
51
+ r = http_status == 200 ? output : 'SERVER ERROR'
52
+ response << r
53
+ response.done
54
+ }
55
+ end
56
+
57
+ def handle_error(status, response, convert = nil)
58
+ response.status = status
59
+ response.headers['Content-Type'] = Rack::Mime.mime_type(::File.extname(error_image))
60
+ if convert # pass error image through original IM command
61
+ EM.system( convert.command(error_image) ){ |output, status|
62
+ response << output
63
+ response.done
64
+ }
65
+ else # just blocking read because user handler blew up
66
+ response << ::File.read(error_image)
67
+ response.done
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+ end
@@ -0,0 +1,148 @@
1
+ module Anisoptera
2
+
3
+ # Simple interface to ImageMagick main commands
4
+ # Most of it borrowed from DragonFly
5
+ # https://github.com/markevans/dragonfly/blob/master/lib/dragonfly/image_magick/processor.rb
6
+ #
7
+ class Commander
8
+
9
+ GRAVITIES = {
10
+ 'nw' => 'NorthWest',
11
+ 'n' => 'North',
12
+ 'ne' => 'NorthEast',
13
+ 'w' => 'West',
14
+ 'c' => 'Center',
15
+ 'e' => 'East',
16
+ 'sw' => 'SouthWest',
17
+ 's' => 'South',
18
+ 'se' => 'SouthEast'
19
+ }
20
+
21
+ # Geometry string patterns
22
+ RESIZE_GEOMETRY = /^\d*x\d*[><%^!]?$|^\d+@$/ # e.g. '300x200!'
23
+ CROPPED_RESIZE_GEOMETRY = /^(\d+)x(\d+)[#|-](\w{1,2})?$/ # e.g. '20x50#ne'
24
+ CROP_GEOMETRY = /^(\d+)x(\d+)([+-]\d+)?([+-]\d+)?(\w{1,2})?$/ # e.g. '30x30+10+10'
25
+
26
+ def initialize(base_path, convert_command = nil)
27
+ @convert_command = convert_command || 'convert'
28
+ @base_path = base_path
29
+ @original = nil
30
+ @geometry = nil
31
+ end
32
+
33
+ def file(path)
34
+ @original = path
35
+ self
36
+ end
37
+
38
+ def encode(format)
39
+ @encode = format
40
+ self
41
+ end
42
+
43
+ def square(size)
44
+ size = size.to_i
45
+ d = size * 2
46
+ @square = "-thumbnail x#{d} -resize '#{d}x<' -resize 50% -gravity center -crop #{size}x#{size}+0+0"
47
+ self
48
+ end
49
+
50
+ def greyscale
51
+ @greyscale = '-colorspace Gray'
52
+ self
53
+ end
54
+
55
+ def thumb(geometry)
56
+ @geometry = case geometry
57
+ when RESIZE_GEOMETRY
58
+ resize(geometry)
59
+ when CROPPED_RESIZE_GEOMETRY
60
+ resize_and_crop(:width => $1, :height => $2, :gravity => $3)
61
+ when CROP_GEOMETRY
62
+ crop(
63
+ :width => $1,
64
+ :height => $2,
65
+ :x => $3,
66
+ :y => $4,
67
+ :gravity => $5
68
+ )
69
+ else raise ArgumentError, "Didn't recognise the geometry string #{geometry}."
70
+ end
71
+ self
72
+ end
73
+
74
+ def mime_type
75
+ ext = @encode ? ".#{@encode}" : File.extname(@original)
76
+ Rack::Mime.mime_type ext
77
+ end
78
+
79
+ # Utility method to test that all commands are working
80
+ #
81
+ def to_file(path)
82
+ File.open(path, 'w+') do |f|
83
+ f.write(`#{command}`)
84
+ end
85
+ end
86
+
87
+ def command(file_path = nil)
88
+ raise ArgumentError, "no original file provided. Do commander.file('some_file.jpg')" unless @original
89
+ file_path ||= full_file_path
90
+ cmd = []
91
+ cmd << @geometry if @geometry
92
+ cmd << @greyscale if @greyscale
93
+ cmd << @square if @square # should clear command
94
+ if @encode
95
+ cmd << "#{@encode}:-" # to stdout
96
+ else
97
+ cmd << '-'
98
+ end
99
+
100
+ "#{@convert_command} #{file_path} " + cmd.join(' ')
101
+ end
102
+
103
+ def check_file
104
+ ::File.exists?(full_file_path)
105
+ end
106
+
107
+ private
108
+
109
+ def full_file_path
110
+ File.join(@base_path, @original)
111
+ end
112
+
113
+ def resize(geometry)
114
+ "-resize \"#{geometry}\""
115
+ end
116
+
117
+ def crop(opts={})
118
+ width = opts[:width]
119
+ height = opts[:height]
120
+ gravity = GRAVITIES[opts[:gravity]]
121
+ x = "#{opts[:x] || 0}"
122
+ x = '+' + x unless x[/^[+-]/]
123
+ y = "#{opts[:y] || 0}"
124
+ y = '+' + y unless y[/^[+-]/]
125
+ repage = opts[:repage] == false ? '' : '+repage'
126
+ resize = opts[:resize]
127
+
128
+ "#{"-resize #{resize} " if resize}#{"-gravity #{gravity} " if gravity}-crop #{width}x#{height}#{x}#{y} #{repage}"
129
+ end
130
+
131
+ def resize_and_crop(opts={})
132
+ if !opts[:width] && !opts[:height]
133
+ return self
134
+ elsif !opts[:width] || !opts[:height]
135
+ attrs = identify(temp_object)
136
+ opts[:width] ||= attrs[:width]
137
+ opts[:height] ||= attrs[:height]
138
+ end
139
+
140
+ opts[:gravity] ||= 'c'
141
+
142
+ opts[:resize] = "#{opts[:width]}x#{opts[:height]}^^"
143
+ crop(opts)
144
+ end
145
+
146
+ end
147
+
148
+ end
@@ -0,0 +1,60 @@
1
+ module Anisoptera
2
+
3
+ module Endpoint
4
+
5
+ def self.factory(config, block)
6
+ if defined?(EventMachine) && Anisoptera.prefer_async
7
+ require 'anisoptera/async_endpoint'
8
+ AsyncEndpoint.new(config, &block)
9
+ else
10
+ require 'anisoptera/sync_endpoint'
11
+ SyncEndpoint.new(config, &block)
12
+ end
13
+
14
+ end
15
+
16
+ def initialize(config, &block)
17
+ @config = config
18
+ @handler = block
19
+ end
20
+
21
+ private
22
+
23
+ # Borrowed from Dragonfly
24
+ #
25
+ def routing_params(env)
26
+ pars = env['rack.routing_args'] ||
27
+ env['action_dispatch.request.path_parameters'] ||
28
+ env['router.params'] ||
29
+ env['usher.params'] ||
30
+ raise(ArgumentError, "couldn't find any routing parameters in env #{env.inspect}")
31
+
32
+ # http_router doesn't parse querystring! Let's make sure we do
33
+ query = Rack::Utils.parse_nested_query(env["QUERY_STRING"]).inject({}) do |mem, (k,v)|
34
+ mem[k.to_sym] = v
35
+ mem
36
+ end
37
+ pars.update(query)
38
+ end
39
+
40
+ def update_headers(commander = nil)
41
+ heads = Anisoptera::HEADERS.dup.update(
42
+ 'X-Generator' => self.class.name,
43
+ 'Last-Modified' => Time.now.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT")
44
+ )
45
+ heads.update(@config.headers) if @config.headers && @config.headers.is_a?(Hash)
46
+ heads['Content-Type'] = commander.mime_type if commander
47
+ heads
48
+ end
49
+
50
+ def error_image
51
+ @config.error_image || ::File.join(File.dirname(__FILE__), 'error.png')
52
+ end
53
+
54
+ def error_status(status)
55
+ @config.error_status || status
56
+ end
57
+
58
+ end
59
+
60
+ end
Binary file
@@ -0,0 +1,35 @@
1
+ # encoding: utf-8
2
+ require 'base64'
3
+
4
+ # Stolen fr... I mean 'borrowed' from DragonFly
5
+ # https://github.com/markevans/dragonfly/blob/master/lib/dragonfly/serializer.rb
6
+ #
7
+ module Anisoptera
8
+ module Serializer
9
+
10
+ # Exceptions
11
+ class BadString < RuntimeError; end
12
+
13
+ extend self # So we can do Serializer.b64_encode, etc.
14
+
15
+ def b64_encode(string)
16
+ Base64.encode64(string).tr("\n=",'')
17
+ end
18
+
19
+ def b64_decode(string)
20
+ padding_length = string.length % 4
21
+ Base64.decode64(string + '=' * padding_length)
22
+ end
23
+
24
+ def marshal_encode(object)
25
+ b64_encode(Marshal.dump(object))
26
+ end
27
+
28
+ def marshal_decode(string)
29
+ Marshal.load(b64_decode(string))
30
+ rescue TypeError, ArgumentError => e
31
+ raise BadString, "couldn't decode #{string} - got #{e}"
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ module Anisoptera
2
+
3
+ class SyncEndpoint
4
+
5
+ include Anisoptera::Endpoint
6
+
7
+ def call(env)
8
+ params = routing_params(env)
9
+ job = Anisoptera::Commander.new( @config.base_path )
10
+ convert = @handler.call(job, params)
11
+
12
+ result = `#{convert.command}`
13
+
14
+ headers = update_headers(convert)
15
+
16
+ [200, headers, [result]]
17
+ end
18
+
19
+ end
20
+
21
+ end
@@ -0,0 +1,3 @@
1
+ module Anisoptera
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,11 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ describe Anisoptera do
4
+
5
+ describe "defaults" do
6
+ it "must prefer async by default" do
7
+ Anisoptera.prefer_async.should be_true
8
+ end
9
+ end
10
+
11
+ end
@@ -0,0 +1,258 @@
1
+ # encoding: utf-8
2
+ require File.dirname(__FILE__) + '/test_helper'
3
+
4
+ require 'thin'
5
+ require 'thin/async'
6
+ require 'thin/async/test'
7
+ require 'rack/test'
8
+ require 'http_router'
9
+ require "base64"
10
+
11
+ $HERE = File.dirname(__FILE__)
12
+
13
+ $CONVERT = "/usr/local/bin/convert"
14
+
15
+ describe 'Anisoptera::AsyncEndpoint' do
16
+ include Rack::Test::Methods
17
+
18
+ def app
19
+ Thin::Async::Test.new(@app)
20
+ end
21
+
22
+ before do
23
+ # redefine config each time to reset app
24
+ Anisoptera[:media].configure do |config|
25
+ config.base_path = File.join($HERE, 'files')
26
+ config.error_status = nil
27
+ config.error_image = nil
28
+ config.convert_command = $CONVERT
29
+ end
30
+
31
+ @app = HttpRouter.new do
32
+ add('/:g/:file').to Anisoptera[:media].endpoint {|image, params|
33
+ image_path = params[:file]
34
+ image.file(image_path).thumb(params[:g])
35
+ image.greyscale if params[:grey]
36
+ image.encode('jpg')
37
+ }
38
+ end
39
+ end
40
+
41
+ shared_examples_for "a cached image" do |status, content_type|
42
+
43
+ it 'should have status 200' do
44
+ last_response.status.should == status
45
+ end
46
+
47
+ it 'should have long-lived headers' do
48
+ last_response.headers["Cache-Control"].should == "public, max-age=3153600"
49
+ end
50
+
51
+ it 'should return Last-Modified header' do
52
+ last_response.headers['Last-Modified'].should == @the_time
53
+ end
54
+
55
+ it 'should return encoded content-type' do
56
+ last_response.headers['Content-Type'].should == content_type
57
+ end
58
+ end
59
+
60
+ shared_examples_for 'resized image data' do |test_image, geometry, content_type|
61
+ it 'should return resized image data' do
62
+ tempimg = File.join($HERE, 'files', "temp.#{content_type}")
63
+ Base64.encode64(`#{$CONVERT} #{test_image} -resize \"#{geometry}\" #{tempimg}`)
64
+ Base64.encode64(last_response.body).should == Base64.encode64(File.read(tempimg))
65
+ File.unlink(tempimg)
66
+ end
67
+ end
68
+
69
+ describe 'success' do
70
+ before do
71
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
72
+ get '/20x20/test.gif'
73
+ end
74
+
75
+ it_behaves_like 'a cached image', 200, 'image/jpeg'
76
+
77
+ it_behaves_like 'resized image data', File.join($HERE,'files','test.gif'), '20x20', 'jpg'
78
+
79
+ end
80
+
81
+ describe 'with querystring' do
82
+ before do
83
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
84
+ get '/20x20/missing.gif?file=test.gif'
85
+ end
86
+
87
+ it_behaves_like 'a cached image', 200, 'image/jpeg'
88
+
89
+ end
90
+
91
+ describe 'with custom headers' do
92
+ before do
93
+ # redefine config each time to reset app
94
+ Anisoptera[:custom].configure do |config|
95
+ config.base_path = File.join($HERE, 'files')
96
+ config.headers = {
97
+ 'Cache-Control' => '1234567890',
98
+ 'X-Custom' => 'custom-head'
99
+ }
100
+ end
101
+
102
+ @app = HttpRouter.new do
103
+ add('/:g/:file').to Anisoptera[:custom].endpoint {|image, params|
104
+ image_path = params[:file]
105
+ image.file(image_path).thumb(params[:g])
106
+ image.encode('jpg')
107
+ }
108
+ end
109
+
110
+ it 'should return encoded content-type' do
111
+ last_response.headers['Content-Type'].should == 'image/jpg'
112
+ end
113
+
114
+ it 'should overwrite passed headers' do
115
+ last_response.headers['Cache-Control'].should == '1234567890'
116
+ end
117
+
118
+ it 'should add passed new headers' do
119
+ last_response.headers['X-Custom'].should == 'custom-head'
120
+ end
121
+
122
+ end
123
+ end
124
+
125
+ describe 'missing image' do
126
+ before do
127
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
128
+ get '/20x20/testfoo.gif'
129
+ end
130
+
131
+ it_behaves_like 'a cached image', 404, 'image/png'
132
+
133
+ it_behaves_like 'resized image data', File.join($HERE,'..','lib','anisoptera', 'error.png'), '20x20', 'jpg'
134
+ end
135
+
136
+ describe 'missing image with error image configured' do
137
+ before do
138
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
139
+ Anisoptera[:media].config.error_image = File.join($HERE, 'files', 'Chile.gif')
140
+ get '/20x20/testfoo.gif'
141
+ end
142
+
143
+ it_behaves_like 'a cached image', 404, 'image/gif'
144
+
145
+ it_behaves_like 'resized image data', File.join($HERE,'files', 'Chile.gif'), '20x20', 'jpg'
146
+ end
147
+
148
+ describe 'with malformed geometry' do
149
+ before do
150
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
151
+ get '/20x20wtf/test.gif'
152
+ end
153
+
154
+ it_behaves_like 'a cached image', 500, 'image/png'
155
+
156
+ it 'should set X-Error header' do
157
+ last_response.headers['X-Error'].should == "Didn't recognise the geometry string 20x20wtf."
158
+ end
159
+ end
160
+
161
+ describe 'with exceptions in custom block' do
162
+
163
+ before do
164
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
165
+
166
+ @app = HttpRouter.new do
167
+ add('/:g/:file').to Anisoptera[:media].endpoint {|image, params|
168
+ raise 'Oops!'
169
+ }
170
+ end
171
+
172
+ get '/20x20/test.gif'
173
+ end
174
+
175
+ it_behaves_like 'a cached image', 500, 'image/png'
176
+
177
+ it 'should set X-Error header' do
178
+ last_response.headers['X-Error'].should == 'Oops!'
179
+ end
180
+
181
+ end
182
+
183
+ describe 'with exceptions and custom config.error_status' do
184
+ before do
185
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
186
+ Anisoptera[:media].config.error_status = 200
187
+
188
+ get '/20x20/testfoo.gif'
189
+ end
190
+
191
+ it_behaves_like 'a cached image', 200, 'image/png'
192
+
193
+ it 'should set X-Error header' do
194
+ last_response.headers['X-Error'].should == 'Image not found'
195
+ end
196
+
197
+ end
198
+
199
+ describe 'with exceptions in custom block and custom error_status' do
200
+
201
+ before do
202
+ Anisoptera[:media].config.error_status = 200
203
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
204
+
205
+ @app = HttpRouter.new do
206
+ add('/:g/:file').to Anisoptera[:media].endpoint {|image, params|
207
+ raise 'Oops!'
208
+ }
209
+ end
210
+
211
+ get '/20x20/test.gif'
212
+ end
213
+
214
+ it_behaves_like 'a cached image', 200, 'image/png'
215
+
216
+ it 'should set X-Error header' do
217
+ last_response.headers['X-Error'].should == 'Oops!'
218
+ end
219
+
220
+ end
221
+
222
+ describe 'with exceptions and config.on_error block' do
223
+
224
+ before do
225
+ @the_time = mock_time("Sun, 20 Nov 2011 01:21:21 GMT")
226
+
227
+ @error_message = ''
228
+ @error_params = nil
229
+ @env = nil
230
+ Anisoptera[:media].config.on_error do |exception, params, env|
231
+ @error_message = 'Oops!'
232
+ @error_params = params
233
+ @env = env
234
+ end
235
+
236
+ @app = HttpRouter.new do
237
+ add('/:gg/:file').to Anisoptera[:media].endpoint {|image, params|
238
+ raise 'Oops!'
239
+ }
240
+ end
241
+
242
+ get '/20x20/test.gif'
243
+ end
244
+
245
+ it 'should have called error block' do
246
+ @error_message.should == 'Oops!'
247
+ @error_params.should == {:gg => '20x20', :file => 'test.gif'}
248
+ @env['SCRIPT_NAME'].should == '/20x20/test.gif'
249
+ end
250
+
251
+ it_behaves_like 'a cached image', 500, 'image/png'
252
+
253
+ it 'should set X-Error header' do
254
+ last_response.headers['X-Error'].should == 'Oops!'
255
+ end
256
+
257
+ end
258
+ end
@@ -0,0 +1,104 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ describe Anisoptera::Commander do
4
+
5
+ describe 'building file path' do
6
+
7
+ before do
8
+ @commander = Anisoptera::Commander.new('/data')
9
+ end
10
+
11
+ it 'must raise error if no file name given' do
12
+ proc {
13
+ @commander.command
14
+ }.should raise_error(ArgumentError)
15
+ end
16
+
17
+ it 'must correct missing slashes' do
18
+ @commander.file('foo.jpg').command.should match(/^convert \/data\/foo\.jpg/)
19
+ @commander.file('/foo.jpg').command.should match(/^convert \/data\/foo\.jpg/)
20
+ end
21
+ end
22
+
23
+ describe 'commands' do
24
+ before do
25
+ @commander = Anisoptera::Commander.new('/data').file('foo.jpg')
26
+ end
27
+
28
+ describe 'resize' do
29
+ it 'should add resize command' do
30
+ @commander.thumb('10x10').command.should == "convert /data/foo.jpg -resize \"10x10\" -"
31
+ end
32
+ end
33
+
34
+ describe 'crop' do
35
+ it 'should add resize command' do
36
+ @commander.thumb('120x120+10+5').command.should == "convert /data/foo.jpg -crop 120x120+10+5 +repage -"
37
+ end
38
+ end
39
+
40
+ describe 'scale' do
41
+ it 'should add scale resize' do
42
+ @commander.thumb('50x50%').command.should == "convert /data/foo.jpg -resize \"50x50%\" -"
43
+ end
44
+ end
45
+
46
+ describe 'bigger / smaller than' do
47
+ it 'should add < or >' do
48
+ @commander.thumb('400x300<').command.should == "convert /data/foo.jpg -resize \"400x300<\" -"
49
+ @commander.thumb('400x300>').command.should == "convert /data/foo.jpg -resize \"400x300>\" -"
50
+ end
51
+ end
52
+
53
+ describe 'positioning (gravity)' do
54
+ it 'should resize with gravity' do
55
+ @commander.thumb('400x300#ne').command.should == "convert /data/foo.jpg -resize 400x300^^ -gravity NorthEast -crop 400x300+0+0 +repage -"
56
+ end
57
+ end
58
+
59
+ describe 'encode' do
60
+ it 'should add format arguments' do
61
+ @commander.encode('gif').command.should == 'convert /data/foo.jpg gif:-'
62
+ end
63
+ end
64
+
65
+ describe 'greyscale' do
66
+ it 'should add colorspace argument' do
67
+ @commander.greyscale.command.should == 'convert /data/foo.jpg -colorspace Gray -'
68
+ end
69
+ end
70
+
71
+ describe 'combined' do
72
+
73
+ it 'should combine commands' do
74
+ @commander.
75
+ thumb('100x100#ne').
76
+ encode('png').
77
+ command.should == "convert /data/foo.jpg -resize 100x100^^ -gravity NorthEast -crop 100x100+0+0 +repage png:-"
78
+ end
79
+
80
+ it 'should accept - as gravity separator because the hash breaks browsers' do
81
+ @commander.
82
+ thumb('100x100-ne').
83
+ command.should == "convert /data/foo.jpg -resize 100x100^^ -gravity NorthEast -crop 100x100+0+0 +repage -"
84
+ end
85
+ end
86
+
87
+ end
88
+
89
+ describe 'mime_type' do
90
+ before do
91
+ @commander = Anisoptera::Commander.new('/data').file('foo.png')
92
+ end
93
+
94
+ it 'should get default from filename' do
95
+ @commander.mime_type.should == 'image/png'
96
+ end
97
+
98
+ it 'should use the one passed to #encode if provided' do
99
+ @commander.encode('gif').mime_type.should == 'image/gif'
100
+ end
101
+
102
+ end
103
+
104
+ end
Binary file
Binary file
@@ -0,0 +1,22 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ describe Anisoptera::Serializer do
4
+ before do
5
+ @data = {:a => 'a', :b => 'b', :c => 11}
6
+ @encoded = Anisoptera::Serializer.marshal_encode(@data)
7
+ end
8
+
9
+ describe "encoding" do
10
+ it "must convert object into a string" do
11
+ @encoded.class.should == String
12
+ end
13
+ end
14
+
15
+ describe 'decoding' do
16
+ it 'should recover original object' do
17
+ decoded = Anisoptera::Serializer.marshal_decode(@encoded)
18
+ decoded.should == @data
19
+ end
20
+ end
21
+
22
+ end
@@ -0,0 +1,19 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ Bundler.setup
4
+
5
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+
8
+ require 'anisoptera'
9
+
10
+
11
+ require 'rspec'
12
+
13
+ def mock_time(atime)
14
+ t = stub('Now')
15
+ Time.stub!(:now).and_return t
16
+ t.stub!(:gmtime).and_return t
17
+ t.should_receive(:strftime).with("%a, %d %b %Y %H:%M:%S GMT").and_return atime
18
+ atime
19
+ end
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: anisoptera
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.0.2
6
+ platform: ruby
7
+ authors:
8
+ - Ismael Celis
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2012-12-04 00:00:00 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: eventmachine
17
+ prerelease: false
18
+ requirement: &id001 !ruby/object:Gem::Requirement
19
+ none: false
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 0.12.10
24
+ type: :runtime
25
+ version_requirements: *id001
26
+ - !ruby/object:Gem::Dependency
27
+ name: thin
28
+ prerelease: false
29
+ requirement: &id002 !ruby/object:Gem::Requirement
30
+ none: false
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: "0"
35
+ type: :runtime
36
+ version_requirements: *id002
37
+ - !ruby/object:Gem::Dependency
38
+ name: thin_async
39
+ prerelease: false
40
+ requirement: &id003 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: "0"
46
+ type: :runtime
47
+ version_requirements: *id003
48
+ - !ruby/object:Gem::Dependency
49
+ name: rack
50
+ prerelease: false
51
+ requirement: &id004 !ruby/object:Gem::Requirement
52
+ none: false
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 1.2.2
57
+ type: :runtime
58
+ version_requirements: *id004
59
+ - !ruby/object:Gem::Dependency
60
+ name: bundler
61
+ prerelease: false
62
+ requirement: &id005 !ruby/object:Gem::Requirement
63
+ none: false
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 1.0.0
68
+ type: :development
69
+ version_requirements: *id005
70
+ - !ruby/object:Gem::Dependency
71
+ name: rack-test
72
+ prerelease: false
73
+ requirement: &id006 !ruby/object:Gem::Requirement
74
+ none: false
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: "0"
79
+ type: :development
80
+ version_requirements: *id006
81
+ - !ruby/object:Gem::Dependency
82
+ name: rspec
83
+ prerelease: false
84
+ requirement: &id007 !ruby/object:Gem::Requirement
85
+ none: false
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: "0"
90
+ type: :development
91
+ version_requirements: *id007
92
+ - !ruby/object:Gem::Dependency
93
+ name: thin-async-test
94
+ prerelease: false
95
+ requirement: &id008 !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: "0"
101
+ type: :development
102
+ version_requirements: *id008
103
+ - !ruby/object:Gem::Dependency
104
+ name: http_router
105
+ prerelease: false
106
+ requirement: &id009 !ruby/object:Gem::Requirement
107
+ none: false
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: "0"
112
+ type: :development
113
+ version_requirements: *id009
114
+ description: You'll need an Eventmachine server such as Thin to run this. See README.'
115
+ email:
116
+ - ismaelct@gmail.com
117
+ executables: []
118
+
119
+ extensions: []
120
+
121
+ extra_rdoc_files: []
122
+
123
+ files:
124
+ - .gitignore
125
+ - .rspec
126
+ - Gemfile
127
+ - README.md
128
+ - Rakefile
129
+ - anisoptera.gemspec
130
+ - examples/http_router.ru
131
+ - examples/pic1.jpg
132
+ - lib/anisoptera.rb
133
+ - lib/anisoptera/app.rb
134
+ - lib/anisoptera/async_endpoint.rb
135
+ - lib/anisoptera/commander.rb
136
+ - lib/anisoptera/endpoint.rb
137
+ - lib/anisoptera/error.png
138
+ - lib/anisoptera/serializer.rb
139
+ - lib/anisoptera/sync_endpoint.rb
140
+ - lib/anisoptera/version.rb
141
+ - spec/anisoptera_spec.rb
142
+ - spec/async_endpoint_spec.rb
143
+ - spec/commander_spec.rb
144
+ - spec/files/Chile.gif
145
+ - spec/files/test.gif
146
+ - spec/serializer_spec.rb
147
+ - spec/test_helper.rb
148
+ homepage: ""
149
+ licenses: []
150
+
151
+ post_install_message:
152
+ rdoc_options: []
153
+
154
+ require_paths:
155
+ - lib
156
+ required_ruby_version: !ruby/object:Gem::Requirement
157
+ none: false
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: "0"
162
+ required_rubygems_version: !ruby/object:Gem::Requirement
163
+ none: false
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: "0"
168
+ requirements: []
169
+
170
+ rubyforge_project: anisoptera
171
+ rubygems_version: 1.8.17
172
+ signing_key:
173
+ specification_version: 3
174
+ summary: Async Rack app for image thumbnailing
175
+ test_files:
176
+ - spec/anisoptera_spec.rb
177
+ - spec/async_endpoint_spec.rb
178
+ - spec/commander_spec.rb
179
+ - spec/files/Chile.gif
180
+ - spec/files/test.gif
181
+ - spec/serializer_spec.rb
182
+ - spec/test_helper.rb