Want to see the full-length video right now for free?
Sign In with GitHub for Free AccessRack is the underlying technology behind nearly all of the web frameworks in the Ruby world. "Rack" is actually a few different things:
Rack defines a very simple interface. Rack compliant code must have the following three characteristics:
call
call
method must accept a single argument - This argument is
typically called env
or environment
, and it bundles all of the data
about the request.call
method must return an array of three elements These elements
are, in order, status
for the HTTP status code, headers
, and body
for
the actual content of the response.A nice side effect of the call
interface is that procs and lambdas can be
used as Rack objects.
The following code sample shows a bare-bones Rack-compliant application that simply returns the text "Hello World".
require "rack"
require "thin"
class HelloWorld
def call(env)
[ 200, { "Content-Type" => "text/plain" }, ["Hello World"] ]
end
end
Rack::Handler::Thin.run HelloWorld.new
In the sample we implement a class with a single instance method, call
,
that takes in the env
and always returns a 200 (HTTP "OK" status), a
"Content-Type" header, and the body of "Hello World".
Note that Rack expects the body portion of the response array to respond to
each
, so bare strings need to be wrapped in an array. More commonly, the
body object will be some other type of IO object, rather than a bare string,
so this wrapping is not needed in those cases.
Lastly, we can see that we've required thin
, a Ruby web server, and by
doing so we can use the Rack compliant handler provided by Thin. Thanks
to the simple nature of the Rack interface, nearly all Ruby web servers have
implemented a Rack handler and we can largely swap them out, without needing
to change our application code. Hooray for useful abstractions!
In our initial sample we simply returned the string "Hello World", but we can
actually replace that body with the env
object to get a peek inside:
class HelloWorld
def call(env)
- [ 200, { "Content-Type" => "text/plain" }, ["Hello World"] ]
+ [ 200, { "Content-Type" => "text/plain" }, env ]
end
end
With that we can see that the env
object is a hash containing all of the
data about our request and response, including things like the user agent
string, cookies, the requested path, etc. All of the values are simply
strings, rather than more structured objects like params
hash in rails, but
everything is included and available for us to inspect and use in composing
our response.
For a deeper dive, check out the full specification of the Rack env object.
Just for fun, and because we can, let's update the hello world sample to use a lambda, rather than a class:
require "rack"
require "thin"
app = -> (env) do
[ 200, { "Content-Type" => "text/plain" }, env ]
end
Rack::Handler::Thin.run app
Middleware are the building blocks of larger applications built using the Rack pattern. Each middleware is a Rack compatible application, and our final application is built by composing together, or nesting these middleware.
Unlike base Rack apps, middleware must be classes as they need to have an initializer which will be passed the next app in the chain. For our first middleware example, we'll introduce a middleware that logs the amount of time the request took and adds that to the response.
To begin, we'll update our core Rack app to sleep for 3 seconds to give us something worth logging, and then we'll build our middleware:
require "rack"
require "thin"
app = -> (env) do
sleep 3
[ 200, { "Content-Type" => "text/plain" }, ["Hello World\n"] ]
end
class LoggingMiddleware
def initialize(app)
@app = app
end
def call(env)
before = Time.now.to_i
status, headers, body = @app.call(env)
after = Time.now.to_i
log_message = "App took #{after - before} seconds."
[status, headers, body << log_message]
end
end
Rack::Handler::Thin.run LoggingMiddleware.new(app)
Here we can see that the "app" we hand off to the Rack thin handler is our base app, wrapped in our logging middleware. In the call method of the middleware we grab a timestamp, then forward on the request to the next "app" in the chain. Our middleware only knows about the very next app, but there could be multiple layers of middleware before our final base app.
On the line where we call the next app, we destructure the array returned by
the app into the status
, headers
, and body
. This allows us to modify or
add to these values as needed.
In the logging middleware's case, we include the status
and headers
in
our return array, but we append the logging time string to the body
, thus
including it in the displayed page.
Thanks to the simplicity and flexibility of the Rack interface, middleware can be used for a large array of different functions. One interesting example of a Rack middleware is rack-honeypot which works to detect and trap spam and malicious bots interacting with your application. It does this appending a hidden field to all responses which only bots are likely to fill in, then parsing all requests and canceling any that have the hidden field filled in.
While the Rack pattern and using middleware is great for composing our
applications together, it can get to be a bit verbose. Rack::Builder
is a
pattern that allows us to more succinctly define our application and the
middleware stack surrounding it.
We can define this in a config.ru
file which is a Rack specific (.ru
stands for "rackup") file, which we can then run directly through the rack
gem's executable:
require "thin"
app = -> (env) do
sleep 3
[ 200, { "Content-Type" => "text/plain" }, ["Hello World\n"] ]
end
class LoggingMiddleware
def initialize(app)
@app = app
end
def call(env)
before = Time.now.to_i
status, headers, body = @app.call(env)
after = Time.now.to_i
log_message = "App took #{after - before} seconds."
[status, headers, body << log_message]
end
end
use LoggingMiddleware
run app
The primary changes are no longer needing to require rack
explicitly, and
using the use
and run
methods to build our application.
The above Rack::Builder
methods can be used more generally to build the
"middleware stack" that we wrap around our application. The middleware are
listed in a specific order and will be run in that order, with a given
request propagating down the stack, eventually reaching our app, then the
resulting response object propagating back up through each middleware.
# sample config.ru defining a middleware stack
use FirstMiddleware
use SecondMiddleware
use LoggingMiddleware
run OurApp.new
Once we have a config.ru
to structure our middleware stack, we can run the
resulting Rack application using the bare rackup
command, provided by the
rack gem. By running through rackup
, rack
will automatically be required
and the DSL of the use
and run
methods will be exposed.
Middleware are perfect for non-app specific logic. Things like setting caching headers, logging, parsing the request object, etc. all are great use cases for Rack middleware. For example, in Rails, cookie parsing, sessions, and param parsing are all handled by Middleware.
Rack provides a clean and powerful abstraction allowing us to separate the core logic of our application from peripheral concerns like parsing HTTP headers. It is the common interface between nearly all Ruby web applications and the web servers we use to run them. Hopefully this episode has given you the context and inspiration to dive in and get to know Rack just a little better. If you're interested in learning more, be sure to check out Joël's in depth overview of Rack.