Saturday, October 10, 2009

Rack Middleware: Driven Almost to Completion

‹prev | My Chain | next›

Continuing the unexpectedly long implementation of my Rack::ThumbNailer Rack middleware, I next describe that the middleware needs to pull images from the target application:
      it "should pull the image from the target application" do
Rack::ThumbNailer.should_receive(:rack_image)
get "/foo.jpg", :thumbnail => 1
end
I make the rack_image another class method due to my inability to stub the target application. To make that pass:
    def call(env)
req = Rack::Request.new(env)
if !req.params['thumbnail'].blank?
filename = "foo"

ThumbNailer.rack_image(@app, env)
ThumbNailer.mk_thumbnail(filename)

thumbnail = ::File.new(filename).read
[200, { }, thumbnail]
else
@app.call(env)
end
end
When creating the thumbnail file, the mk_thumbnail class method should use the result of ThumbNailer.rack_image. To describe this in RSpec, I add a default output for ThumbNailer.rack_image in the before(:each) setup block:
    context "with thumbnail param" do
before(:each) do
#...
Rack::ThumbNailer.
stub!(:rack_image).
and_return("image data")
end
I can then specify that mk_thumbnail should use the return value of rack_image ("image data"):
      it "should generate a thumbnail" do
Rack::ThumbNailer.
should_receive(:mk_thumbnail).
with(anything(), "image data")
get "/foo.jpg", :thumbnail => 1
end
To make that pass, I add a second argument in the call method:
    def call(env)
req = Rack::Request.new(env)
if !req.params['thumbnail'].blank?
filename = "foo"

image = ThumbNailer.rack_image(@app, env)
ThumbNailer.mk_thumbnail(filename, image)

thumbnail = ::File.new(filename).read
[200, { }, thumbnail]
else
@app.call(env)
end
end
Rather than stick with the anthing() matcher in the mk_thumbnail example, I can specify the filename for the cached copy of the image. It should be a concatenation of the cache directory, plus the path_info for the image in the target application.

To support this, I add a default cache directory to the constructor for Rack::ThumbNailer:
  class ThumbNailer
DEFAULT_CACHE_DIR = '/var/cache/rack/thumbnails'

def initialize(app, options = { })
@app = app
@options = {:cache_dir => DEFAULT_CACHE_DIR}.merge(options)
end
...
The make-the-thumbnail-file example can then read:
      it "should generate a thumbnail" do
Rack::ThumbNailer.
should_receive(:mk_thumbnail).
with("/var/cache/rack/thumbnails/foo.jpg", "image data")
get "/foo.jpg", :thumbnail => 1
end
To make that pass, I update the call method to:
    def call(env)
req = Rack::Request.new(env)
if !req.params['thumbnail'].blank?
filename = @options[:cache_dir] + req.path_info

image = ThumbNailer.rack_image(@app, env)
ThumbNailer.mk_thumbnail(filename, image)

thumbnail = ::File.new(filename).read
[200, { }, thumbnail]
else
@app.call(env)
end
end
Nice, that is a full-featured thumbnail generating bit of Rack middleware, save for the two class methods, which I largely borrow from my spike the other day (I deleted the code, but kept the notes):
    private
def self.rack_image(app, env)
http_code, headers, body = app.call(env)

img_data = ''
body.each do |data|
img_data << data
end
img_data
end


def self.mk_thumbnail(filename, image_data)
path = filename.sub(/\/[^\/]+$/, '')
FileUtils.mkdir_p(path)

ImageScience.with_image_from_memory(image_data) do |img|
img.resize(200, 150) do |small|
small.save cache_filename
end
end
end
I would prefer to test these as well, but my inability to set expectations / mock these instance method makes such an endeavor more trouble that it is worth. So I keep them small, focused and rely on the axiom that one does not test private methods.

Tomorrow, I will add cache hit/miss functionality and that should finish of my middleware.

1 comment: