Rack has proven to be a great way for developers to quickly modify the request/response cycle for Ruby web applications.
Rack provides a minimal, modular and adaptable interface for developing web applications in Ruby. By wrapping HTTP requests and responses in the simplest way possible, it unifies and distills the API for web servers, web frameworks, and software in between (the so-called middleware) into a single method call.
- from the Rack docs
This article assumes a basic understanding of Ruby lambdas. Robert Sosinski has a good write up on Ruby Blocks, Procs and Lambdas if you need a refresher.
The lambda method converts a block to a Proc that can be invoked with the call method.
my_lambda = lambda {puts 'Proc Intro'} my_lambda.call # => Proc Intro
lambdas can also take parameters:
param_lambda = lambda { |param| puts "Proc Intro: #{param}"} param_lambda.call('hello world') # => Proc Intro: hello world
This is all fine-and-good, pretty standard Ruby stuff. Rack works well when you start chaining these together. Rack responses return an array of three items: the status, headers, and response body: [status, headers, response_body]
. Let's setup a starting point for our response (env
) and couple of basic Rack style lambdas.
env = [200, {"Content-Type" => "text/plain"}, "hello"] # => [200, {"Content-Type"=>"text/plain"}, "hello"] body_lambda = lambda { |env| [200, {"Content-Type" => "text/plain"}, env[2]+" from a proc"] } body_lambda.call(env) # => [200, {"Content-Type"=>"text/plain"}, "hello from a proc"] header_lambda = lambda { |env| [200, env[1].merge({"X-Authorization" => "secret"}), env[2]] } header_lambda.call(env) # => [200, {"Content-Type"=>"text/plain", "X-Authorization"=>"secret"}, "hello"]
Now, to chain them together we just call one with the results of the other.
env = [200, {"Content-Type" => "text/plain"}, "hello"] header_lambda.call(body_lambda.call(env)) # => [200, {"Content-Type"=>"text/plain", "X-Authorization"=>"secret"}, "hello from a proc"]
Attentive readers will notice that if we run this the other way we get less desirable results:
body_lambda.call(header_lambda.call(env)) # => [200, {"Content-Type"=>"text/plain"}, "hello from a proc"]
We lost the header changes because the body_lambda
is strictly returning {"Content-Type" => "text/plain"}
. This is a gotcha to be aware of when writing middleware. At any point in the chain you can completely reset any part of the return value. You need to be sure to pass on things that should be passed on and only update things your middleware is responsible for. Lets fix body_lambda
so that it will pass along any response headers:
body_lambda = lambda { |env| [200, env[1], env[2]+" from a proc"] }
Now the following two calls will give the same result:
body_lambda.call(header_lambda.call(env)) header_lambda.call(body_lambda.call(env))
Rack and other similar tools allow lambdas to be chained together in order to provide a generic framework for handling the request/response. The meat of this is the Enumerable inject method. The inject method will walk over an Enumerable applying a method to the results of the previous iteration and return the final iteration. In our case we pass our env
array as a starting point.
[body_lambda, header_lambda].inject(env) { |app, handler| handler.call(app) } # => [200, {"Content-Type"=>"text/plain", "X-Authorization"=>"secret"}, "hello from a proc"]
Unwrapped this is the same as:
header_lambda.call(body_lambda.call(env))
Both of our lambdas expect an array to do their work on, so we give them the env
array as a starting point. This is important because without it every lambda would have to deal with cases in which there is no value passed.
Rack puts chaining lambdas together by providing a wrapper class to build an array of lambdas. Before we get into building a wrapper, let's take a look at how we can build classes that represent our header_lambda
and body_lambda
from above.
class Header # new header_lambda def initialize(app) @app = app; end def call(env) status, headers, response = @app.call(env) [status, headers.merge({"X-Authorization" => "secret"}), response] end end class Body # new body_lambda def initialize(app) @app = app; end def call(env) status, headers, response = @app.call(env) [status, headers, "#{response} from Body"] end end inital_response_lambda = lambda { |env| [200, {}, "starting"] } h = lambda { |app| Header.new(app) } h.call(inital_response_lambda) # => #<Header:xxx @app=#<Proc:xxx@(irb):1 (lambda)>>
So h
represents the lambda containing Header#new
, which expects a lambda representing the current state. The response here is the Header
class inside lambda { |app| Header.new(app) }
. We have to send the call
method again to get the response. But notice that Header#call
uses @app.call(env)
to set the responses. At this point, it doesn't matter what we send the call method.
h.call(inital_response_lambda).call('go baby go') # => [200, {"X-Authorization"=>"secret"}, "starting"]
Why then should we use the call
method in the Header
or Body
classes? Doesn't it seem to add unnecessary confusion? I think it makes the internal code more confusing, but it also makes the API it provides pretty simple. Let's use the inject
method used above to put this together.
lambdas = [] lambdas << lambda { |app| Header.new(app) } lambdas << lambda { |app| Body.new(app) } lambdas.inject(inital_response_lambda) { |app, handler| handler.call(app) } # => #<Body:xxx @app=#<Header:xxx @app=#<Proc:xxx@header.rb:18 (lambda)>>>
It's not pretty on the console, but the inject method has returned an instance of the Body
class containing an instance of Header
which in turn contains our inital_response_lambda
. Sending call
to this with any parameter will use the Body
instance to unravel the whole stack.
Body @app.call('go baby go') Header @app.call('go baby go') inital_response_lambda.call('go baby go') # => returns [200, {}, "starting"] Header @app.call([200, {}, "starting"]) # => returns [200, {"X-Authorization"=>"secret"}, "starting"] Body @app.call([200, {"X-Authorization"=>"secret"}, "starting"]) # => returns [200, {"X-Authorization"=>"secret"}, "starting from Body"]
At this point the whole thing looks like:
class Header def initialize(app) @app = app; end def call(env) status, headers, response = @app.call(env) [status, headers.merge({"X-Authorization" => "secret"}), response] end end class Body def initialize(app) @app = app; end def call(env) status, headers, response = @app.call(env) [status, headers, "#{response} from Body"] end end inital_response_lambda = lambda { |env| [200, {}, "starting"] } lambdas = [] lambdas << lambda { |app| Header.new(app) } lambdas << lambda { |app| Body.new(app) } lambdas.inject(inital_response_lambda) { |app, handler| handler.call(app) }.call('go baby go') } # => [200, {"X-Authorization"=>"secret"}, "starting from Body"]
Now that we know how to build an array of lambdas and what it takes to run them we can put it all into a class.
class Builder def initialize(&block) @lambdas = [] instance_eval(&block) if block_given? end def use(middleware, *args, &block) @lambdas << if block_given? lambda { |app| middleware.new(app, *args, &block) } else lambda { |app| middleware.new(app, *args) } end end def to_app inital_response_lambda = lambda { |env| [200, {}, ""] } @lambdas.inject(inital_response_lambda) { |app, handler| handler.call(app) } end def run to_app.call('go baby go') # remember this is thrown away when the call's get back to inital_response_lambda end end
We can create and run an instance of the builder class like so:
app = Builder.new { use Header use Body } app.run # => [200, {"X-Authorization"=>"secret"}, "starting from Body"]
Summary: A look at the internals of how Rack builds middleware for flexible request processing.