noteflakes

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:

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:

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 included 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.