Qeweney - a feature-rich HTTP request/response API for Ruby
03·12·2021
As you may know, in the last few months I’ve been working on Tipi, a new web server for Ruby, with innovative features such as support for HTTP/2, automatic SSL certificates, and streaming request and response bodies. As part of the development process, I also had to deal with how to represent HTTP requests and responses internally.
Tipi being a modern web server, with emphasis on concurrency, performance, and streaming, I felt it was wrong to base it on the Rack interface. While Rack is today ubiquitous in the Ruby ecosystem—it underlies basically all Ruby web frameworks and all Ruby app servers—it has some important limitations, especially as regards being able to perform HTTP upgrades and the streaming of request and response bodies.
Instead, the interface I came up with is a single Request
class, with an
imperative API design. I have extracted this interface into a separate gem, for
reasons that I’ll discuss below. I’m calling this interface
Qeweney (pronounced “Q ‘n’ A”). As
we’ll see, Qeweney can be used inside of a Rack app, and can also be used to
drive a Rack app.
The Request interface
Qeweney exposes a single class,
Request
.
An instance of Request
is initialized with headers and an adapter. The adapter is the
party actually responsible for the client connection. Tipi implements separate
adapters for
HTTP/1
and
HTTP/2
connections. In the case of HTTP/2, the actual request adapter represents a
single HTTP/2 stream, backed by an HTTP/2 connection adapter, as shown in the
following diagram:
+------+ +-------+ +-------+ +-----+
|Client|--->|HTTP/1 |------------------>|Request|--->| |
| |<---|adapter|<------------------| |<---| |
+------+ +-------+ +-------+ | |
| |
+------+ +-------+ +---------+ +-------+ | |
|Client|--->| |--->|H2 stream|--->|Request|--->| Web |
| |<---| |<---|adapter |<---| |<---| app |
+------+ |HTTP/2 | +---------+ +-------+ | |
|adapter| ... | |
| | +---------+ +-------+ | |
| |--->|H2 stream|--->|Request|--->| |
| |----|adapter |<---| |<---| |
+-------+ +---------+ +-------+ +-----+
This design provides separation between the HTTP semantic layer (the representation of different parts of HTTP requests and responses,) and the HTTP transport layer, which changes according to the HTTP protocol version. In addition, this design facilitates the testing of HTTP requests and responses, for example with a mock request adapter. It also allows the implementation of other transport methods, such as HTTP/3, or other custom transport protocols for implementing custom HTTP proxies (I already have some ideas I’ll write about in the future.)
The Request
API includes methods for inspecting the request headers, reading
the request body, and finally responding to the request. The most important
differentiator between the Qeweney API and the Rack interface is that with
Qeweney the response is generated imperatively, by using one of the response
APIs, while in Rack the response is the return value of an app function that
takes a request as argument. Let’s compare the two approaches:
# Rack
app = ->(env) do
[
200,
{'Content-Type' => 'text/plain'},
['Hello, world!']
]
}
# Qeweney
app = ->(req) do
req.respond('Hello, world!', 'Content-Type' => 'text/plain')
end
As shown above, a Rack app is a Proc
that takes a Hash
as its env
argument
(containing information about the request and the server) and returns an Array
consisting of the HTTP status code, response headers and the response body
reprersented as an array.
In contrast, a Qeweney-based app takes a Qeweney::Request
as its argument, and
it is the app’s responsibility to respond to the request by explicitly calling
one of the response methods, such as #respond
, #send_headers
, #send_chunk
and #finish
.
Request information
One of the most crucial design decisions I made early on with Tipi was to allow handling incoming requests without first reading and parsing the request body. In Tipi, as soon as all request headers have been received, the request is created and passed to the app. This allows an app to reject invalid or malicious requests before the request body is read. It also allows Ruby apps to apply backpressure, as they can control reading the request body.
With that in mind, let’s examine how the request information is actually
represented in Qeweney::Request
. As we saw, a request is instantiated by
providing it with an adapter instance, and a hash containing the request
headers. The header keys are represented by convention using lower case, and
also include the following meta-headers:
:method
- the HTTP method used (represented as an upper-case string.):path
- the path specified in the HTTP request line (including query parameters.):scheme
- the protocol scheme used, i.e.http
orhttps
.
It is important to note that these conventions concerning request headers should be adhered to by any request adapter. Finally those are the conventions that allow apps to behave identically for both HTTP/1 and HTTP/2 connections.
In order to facilitate the processing of request headers, Qeweney includes a
module named
RequestInfoMethods
into the Request
class. These method help us deal with such things as URL
queries
and
cookies.
Here’s an example of how these methods can be used:
app = ->(req) do
session = find_session(req.cookies['session-id'])
validate_csrf_token(session, req.headers['x-csrf_token'])
page_no = (req.query('page') || 1).to_i
serve_page(req, page_no)
end
Request body
Once the app is ready to read the request body, it can use Request#read
for
reading the entire request body, or Request#next_chunk
and
Request#each_chunk
to read the request body in chunks.
Here’s an example of how #each_chunk
can be used to create a server that
responds with the request body converted to upper case:
upper_caser = ->(req) do
req.send_headers
req.each_chunk { |c| req.send_chunk(c.upcase) }
req.finish
end
In order to facilitate the handling of forms, Qeweney also includes the
Request.parse_form_data
method which can be used to handle form submission:
form_handler = ->(req) do
form = Qeweney:::Request.parse_form_data(req.read, req.headers)
submit_form(form)
req.respond(nil, ':status' => Qeweney::Status::CREATED)
end
Responding
When it’s time to send out a response, Qeweney::Request
provides the following
methods:
#respond(body, headers)
- send out a complete response#send_headers(headers)
- send out headers, without finishing the response#send_chunk(chunk, done: false)
- send a body chunk, possibly finishing the response#finish()
- finish the response (when not usingrespond
)
These APIs allow the app to either send the body all at one, or as shown in the
upper_caser
example above, chunk by chunk. Note that Tipi’s HTTP/1 adapter
will use chunked
encoding by default,
which lets apps send out streaming responses easily. By being able to send a
response chunk by chunk, apps are able to generate large responses without
having to buffer them in memory, thus lowering pressure on the Ruby GC, and the
app’s memory footprint in general.
In a similar manner to the way Qeweney provides additional methods for
processing request headers, it also provides response methods (defined in the
Qeweney::ResponseMethods
module)
for generating responses more easily. Here are some of them:
# redirect to another URL
req.redirect(alternative_url)
# redirect to HTTPS
req.redirect_to_https
# serve a static file
req.serve_file(file_path)
# serve from an IO
req.serve_io(io)
# serve from a Rack app
req.serve_rack(app)
# Upgrade to arbitrary protocol
req.upgrade(protocol)
Now these methods may seem almost banal (and they mostly are,) but #serve_file
method merits closer scrutiny, as it actually packs a lot of power behind its
modest method signature. It sets cache headers, validates If-None-Match
and
If-Modified-Since
headers included in the request, and responds with a 304
Not Modified
status code if the client’s cache is valid. It also serves the
file compressed according to the Accept-Encoding
request header, using either
the deflate
or gzip
algorithms.
Tipi further turbocharges the functionality provided by Qeweney using optimized,
protocol-specific methods for serving arbitrary responses from an IO object
(Request#serve_file
uses #serve_io
under the hood). Tipi’s HTTP/1 adapter is
capable of sending requests from an IO by
splicing
them to the client’s socket connection, achieving up to 64% better throughput
for large files.
Request routing
Qeweney also includes a basic API for routing
requests
without needing a web framework, or rolling your own. The Qeweney routing is
largely inspired by Roda, and in fact uses the
same mechanism (using catch/throw
) behind the scenes. Here’s a simple
demonstration of what Qeweney’s router is capable of:
app = ->(r) do
r.route do
r.on_root { r.redirect '/hello' }
r.on('hello') do
r.on_get('world') { r.respond 'Hello world' }
r.on_get { r.respond 'Hello' }
r.on_post do
r.redirect '/'
end
r.default { r.respond nil, ':status' => 404 }
end
end
end
The Qeweney router is by no way comprehensive (it probably doesn’t have half the features of Roda,) nor does it have the best routing DSL, at least as far as I’m concerned, but it offers a solid starting point for implementing web apps on top of Qeweney, without needing to use any web framework.
Extending Qeweney
Qeweney’s API largely consists of a single class, Qeweney::Request
, which
deals with all aspects of processing HTTP requests and responses, from the point
of view of the server. The different functionalities—request information,
responding, and routing—are implemented in separate modules that are then
include
d into the Request
class.
This design choice means that you don’t need to deal with different objects and
different classes, everything related to requests and responses available
directly on the Request
instance given to your web app. The downside is that
the Request
class has a big surface area with dozens of methods, and will most
probably not satisfy OOP purists who demand no more than 10 methods per class,
and a single area of responsibility for each class. YMMV.
The point I want to make here, is that to me, adding request/response
functionality in this manner, by extending the Request
class, makes a lot more
sense, especially when you want to add middleware to your app, which brings us
to:
Qeweney and Middleware
One of the defining features of Rack is the use of middleware - small pieces of
specific HTTP functionality that you can plug into your application, without
making any change to your app’s logic. This is due to Rack being a functional
interface (you feed a request in, and you get a response out.) All you need to
do to plug a middleware into your app is to call the middleware instead of your
app, and have the middleware call your app before or after it has done its
business. That way, a middleware can manipulate both the env
parameter passed
to your app, as well as the response your app has generated, before it is being
returned to the app server.
In fact, you can put a whole bunch of middleware components in a pipe line, with each of them performing specific tasks before and after your app has dealt with the request.
As we saw above, Qeweney does not use a functional approach, but rather an imperative one. Once the app has sent its response (by calling methods on the given request,) there’s no way to change it post factum.
So how can we use middleware under such circumstances? We do not necessarily
want to monkey-patch Qeweney::Request
in order to provide specific
functionality, such as logging, validating request headers or setting various
response headers. In fact, it would be undesirable to change the behaviour of
the class, when all we need is to apply custom behaviour for specific instances
of the class. For example, we might want to apply CSRF
protection only to
some requests but not to others. We might want to measure response latency for
some requests but not others.
Luckily, Ruby lets us do just that by letting us extend
object instances with
methods from arbitray modules. Let’s see how this technique can be applied to
creating middleware in Qeweney:
module JSONContentTypeExtensions
# overrides Qeweney::Request#respond
def respond(body, headers = {})
headers = headers.merge('Content-Type' => 'application/json')
super(body, headers)
end
end
def with_json_content_type(app)
->(req) do
req.extend(JSONContentTypeExtensions)
app.(req)
end
end
payload = { 'message': 'Hello world!' }
app = ->(req) { req.respond(payload.to_json) }
# plug middleware in front of app
app = with_json_content_type(app)
The above example shows how a module modifying the stock behaviour of
Qeweney::Request#respond
can be used to extend a specific instance of the
class. Note how we call super
in the patched method. The
with_json_content_type
builds the middleware by taking an app and returning a
modified Proc
that first extends the request with the custom behaviour, then
passes it on to the app.
Here’s another example that shows how we can implement a logging middleware:
def with_logger(app, log)
m = Module.new do
define_method(:respond) do |body, headers = {}|
start = Time.now
super(body, headers)
elapsed = Time.now - start
log.info "Got #{self.headers}, respond with #{headers.inspect} (#{elapsed}s)"
end
end
->(req) do
app.(req.extend(m))
end
end
This example is quite dense, so let’s analyze what it does. Since we want to be
able to inject a log
instance into the middleware, we need to dynamically
override the #repsond
method with a closure, in order to be able to access
log
from within the method. Once that has been done, with_logger
returns a
Proc
that first extends the request with the dynamically defined module, then
calls the app.
Finally, in order to make it easier to write middleware for Qeweney, it will eventually include a simple DSL for writing middleware. It will probably look something like the following:
with_logger = Qeweney.middleware do |log|
respond do |body, headers = {}|
start = Time.now
super(body, headers)
elapsed = Time.now - start
logger.info "Responded with #{headers.inspect} (#{elapsed}s)"
end
end
with_json_content_type = Qeweney.middleware do
respond do |body, headers = {}|
headers = headers.merge('Content-Type' => 'application/json')
super(body, headers)
end
end
payload = { 'message': 'Hello world!' }
app = ->(req) { req.respond(payload.to_json) }
# plug middleware
app = with_logger(with_json_content_type(app))
What about Rack?
Last, but not least, I’d like to discuss how Qeweney fits in with a world where practically all Ruby web apps are based on Rack. Qeweney can be used both as a driver for Rack apps (e.g. if you want to run your Rack app on Tipi,) and to run Qeweney apps on top of Rack servers such as Puma or Falcon.
In order to run your Rack app using Tipi, you don’t need to do anything. Just
provide run Tipi with the path of your rack app, e.g.: tipi myapp.ru
, and Tipi
will take care of doing the
translation.
If you prefer to develop your next web app using the Qeweney interface, it’s easy to convert it to a Rack app. Here’s how:
require 'qeweney'
my_qeweney_app = ->(req) do
req.respond('Hello world!')
end
run Qeweney.rack(&my_qeweney_app)
Conclusion
This has been an introduction to Qeweney, a new HTTP request/reponse interface for Ruby web apps and servers. Its design was driven by the need for concurrency, performance, and streaming capabilities that are currrently lacking in Rack. While most developers using Tipi and the suite of tools I’m currently developing, will not interact directly with Qeweney, I wanted to give you an overview of its API and show some of its capabilities. If this work interests you, please let me know by contacting me. We can also hook up on GitHub.