This is a record of the decisions that have gone into the Ring 2.0 specification. As of writing, the specification is still a draft, and should not be considered finalized.
The most obvious change in the 2.0 draft specification is that the keys for request and response maps are now namespaced.
This change updates the request and response maps to be more in line with the future direction of Clojure. Core libraries such as Spec, and tools from Cognitect such as Datomic make extensive use of namespaced keywords. In addition the language itself has moved toward more support for namespaced keywords, such as in the recent changes destructuring, and the syntax sugar for maps that have keys with a common namespace.
Namespaced keywords also make request and response maps unambiguous.
If a map contains :ring.request/method
, then it must be a request
map; if a map contains :ring.response/status
then it must be a
response.
It's also possible that we can namespace keys added via middleware, therefore distinguishing between keys from the adapter, and keys derived via calculation such as cookies or URL-encoded parameters.
An key benefit to being able to identify requests and responses is that we can retain backward compatibility across middleware and utility functions. We can efficiently distinguish between requests that adhere to the 1.x specification, and those that adhere to the 2.x specification, and invoke different behavior depending on the type of input.
As an example:
(defn wrap-example [handler]
(let [handler-1 (wrap-example-1 handler)
handler-2 (wrap-example-2 handler)]
(fn [request]
(if (:ring.request/method request)
(handler-2 request)
(handler-1 request)))))
In informal benchmarks, looking up a key in a small request map was around 6ns. This is relatively little overhead, even across a chain of middleware functions. Additionally this should lend itself to hardware branch prediction, as it's expected that an application should use one version of the Ring spec.
In the Ring 1 spec there were a number of required keys for both request and response maps. In the 2.0 spec, this is reduced to one required key for requests, and one required key for responses.
Example:
#:ring.request{:method :get}
#:ring.response{:status 200}
Having at least one mandatory key is necessary for efficient identification of requests and responses. However, too many requirements make it difficult to build requests that may have partial information.
In Ring 1, the :server-port
, :server-name
and :remote-addr
keys are required. These provide useful information about the
underlying TCP connection, but a HTTP request could be delivered
through a Unix socket, or stored in a file. These aren't necessarily
part of what makes a HTTP request, so they shouldn't be required.
The next required key in Ring 1 is :uri
. This represents the path
component of the request URI (see the Key Changes section). A HTTP
request does not necessarily include a path to a resource. The request
URI, later referred to as the request target, may be "*" or an
authority. For example:
OPTIONS * HTTP/1.1
And:
CONNECT www.example.com:443 HTTP/1.1
Again, this means the path should not be required.
The :scheme
key is required in Ring 1, but again this isn't always
known, as a HTTP request may be recorded in a file or passed through a
proxy. Therefore this key is made optional.
The :request-method
key is required in Ring 1, and will be required
for Ring 2 also. It's both a mandatory part of all HTTP requests,
whatever the version of the protocol, and it's a necessary element to
check. Methods like HEAD
and OPTIONS
will always behave
differently to other methods.
The :protocol
key is part of the request line in HTTP 1.1, and also
part of the connection preface of HTTP/2. It's a known quantity, but
in general not a useful one. We rarely need to know the protocol used
in order to process a HTTP request, and in general the protocol of
the request doesn't affect the content of the response. For this
reason, the protocol is optional in Ring 2.
Finally, the :headers
key is required in Ring 1, but in Ring 2 it
will be optional, as an empty map of headers can be considered
equivalent to a lack of any header key in the request.
The response map in Ring 1 has two required keys: :status
and
:headers
.
The status is a mandatory part of a HTTP response, so it remains a required key in Ring 2.
The headers key will be optional, for the same reasons request headers have been made optional.
Both request and response maps in Ring 2 have protocols that describe
the request and response body. These protocols could be placed in
other namespaces, but as they're a key part of their respective maps,
it's more logical and convenient to place them in ring.request
and
ring.response
namespaces
As a side benefit, this allows developers to take advantage of namespace aliasing to build more concise response maps.
(require '[ring.response :as resp])
(defn handler [request]
#::resp{:status 200
:headers {"content-type" ["text/plain"]}
:body "Hello World"})
In Ring 1, the request body is an java.io.InputStream
object. In
Ring 2, the request body must at least satisfy the
ring.request/StreamingRequestBody
protocol.
A request body may choose to satisfy other protocols. In future, a protocol for asynchronous reads of the request body will be introduced. However, the fallback of a streaming request protocol will always be available, at least for any JVM implementations of Ring.
This limits compatibility with platforms that use asynchronous I/O exclusively, such as ClojureScript. However, we have reader conditionals for just such cases, and these would need to be used anyway to selectively remove Ring's synchronous handlers in middleware and the like.
Having a streaming protocol to fall back on on the JVM is useful for
both being able to quickly read in the request body without requiring
a callback, and because Java servlets also use an InputStream
to
represent the request body, even for the asynchronous I/O introduced
in the Servlet 3.1 specification.
In practice, this change will make reading the request body slightly more verbose. Contrast a handler written for Ring 1:
(defn handler [request]
(let [name (-> request :body slurp)]
{:status 200, :body (str "Hello " name)}))
With a handler written for Ring 2:
(require '[ring.request :as req]
'[ring.response :as resp])
(defn handler [request]
(let [name (-> request :body req/get-body-stream slurp)]
#::resp{:status 200, :body (str "Hello " name)}))
While this is somewhat harder to write, it does break Ring's reliance on blocking I/O. It also allows us to write requests for tests more easily, as strings can be made to satisfy the protocol. So In Ring 1:
(require '[ring.util.io :as rio])
{:request-method :post
:uri "/"
:headers {}
:body (rio/string-input-stream "Hello World")}
While in Ring 2:
(require '[ring.request :as req])
#::req{:method :post
:path "/"
:body "Hello World"}
In Ring 1, request headers are String
to String
maps that relate
lowercased header names with their corresponding values.
In HTTP, there can be multiple headers with the same name. For example:
Accept: text/html
Accept: application/json
This is equivalent to separating header values with a comma:
Accept: text/html, application/json
And as header names are case insensitive, this is equivalent to:
accept: text/html, application/json
In Ring 1, all headers are normalized to this format:
{"accept" "text/html, application/json"}
The lowercased header names allow for map lookup, and by concatenating header values we can ensure that all values are strings, making it easier for developers to deal with the most common case, where a header will have a single value.
However, this approach prioritizes 'easy' over 'simple'. If there are multiple headers they have to be joined and then later split apart once again. And from a semantic point of view, a string is a poor representation of an ordered collection of values.
In Ring 2, we map a lowercase String
to a vector of String
s:
{"accept" ["text/html" "application/json"]}
This applies even in the single case:
{"content-type" ["text/html"]}
This does make accessing request headers with single values slightly more laborious:
(first (get-in request [:headers "content-type"]))
However, this disadvantage can be offset by utility functions:
(get-header request :content-type)
It also ensures that you don't accidentally start processing a concatenated list of header values.
Still to be determined: whether or not an adapter should automatically split apart concatenated header values.
In Ring 1 this is named :request-method
. In Ring 2 we can drop the
request-
part as that is included in the namespace.
In Ring 1 this was named :uri
. This corresponds to the
getRequestURI
method of the HttpServletRequest
class.
However, despite the name, the getRequestURI
method does not
directly correspond to the Request-URI
detailed in RFC 2616, as
one might expect. Instead, this represents the path component of the
request URI, as defined in RFC 3986
In RFC 7230 the Request-URI
is renamed to the request-target
,
and in HTTP/2 the request target is split into :path
and
:authority
pseudo-headers (see RFC 7540).
Given all this, it makes most sense to refer to change the :uri
key
to :ring.request/path
in Ring 2, as that's more consistent with
terminology in both the URI RFC and the HTTP/2 RFC.
It should be noted that the :path
HTTP/2 pseudo-header includes the
query string, while the path
of a URI excludes it. As the query
string is split out by default by most server implementations, and is
most useful to be considered separately, Ring 2 will keep the path and
query separate.
In Ring 1 this is named :query-string
. In RFC 3986 this is
referred to as the query
. For consistency, and because the -string
part is somewhat redundant, in Ring 2 this key has been renamed to
:ring.request/query
.
The Ring 1 specification is very permissible when it comes to response headers. The header name need not be lowercase, and the header value may be a string or a vector of strings.
For example, this is a permissible response header map:
{"x-foo" "1"
"X-Foo" "2"
"X-foo" ["3" "4"]}
This makes it hard to build middleware that modify the response headers. By contrast, Ring 2 mandates that response header names be lowercase, and the values be vectors:
{"x-foo" ["1" "2" "3" "4"]}
This also ensures that response and request headers have a single, consistent format.
In HTTP/1 all header names are case insensitive, and in HTTP/2 header names have to be lowercased.
A Ring websocket API has been discussed a number of times over Ring's history without a concrete resolution. Previously my feeling was that websockets were out of scope for Ring, but I've since reconsidered.
Websockets are a separate protocol to HTTP, but they share a common initial request, and if there is an error or if access is denied, a HTTP response may be returned instead of a websocket. Given this, it makes sense to model a websocket as a special kind of response to a Ring request.
Secondly, no websocket standard has yet emerged outside of Ring. Current websocket implementations are bespoke, tied to one particular web server. A websocket abstraction standard would allow the same websocket code to be used against multiple webservers, and Ring is a good place for such a standard to live.
Many websocket APIs, most notably the one for Javascript, provide some sort of listener interface where different events are passed to different methods. In Clojure this approach would be achieved through a protocol:
(require '[ring.websocket :as ws])
(reify ws/Listener
(on-open [_ socket] ...)
(on-message [_ socket message] ...)
(on-close [_ socket code reason] ...)
(on-error [_ socket throwable] ...))
An alternative approach is to have a single function that receives data from all events:
(fn [event]
(case (::ws/event event)
:open ...
:message ...
:close ...
:error ...))
Equally we can handle sending and closing a channel via a protocol:
(reify ws/Listener
(on-open [_ socket]
(ws/send socket "Hello")
(ws/close socket 1000)))
Or via a callback that receives a data structure:
(fn [event send]
(send {::ws/text-data "Hello"})
(send {::ws/close-status 1000}))
Or even via two callbacks, one to send and one to close:
(fn [event send close]
(send "Hello")
(close 1000))
Out of these possible designs, Ring 2 has opted to use protocols for both the listener and the socket. The reason for this design is covered by the next section.
Ring uses functions and maps to handle HTTP, so the natural question to ask is: why not use the same design for websockets? Why not use a function that sends and receives maps, instead of a protocol?
HTTP, even modern versions of it, is a request/response protocol. Each request to the server results in a corresponding response. Even push promises don't fundamentally change this; they're just a predictive tool for sending responses for requests that may occur in future. This mirrors the operation of a function, which takes an argument as input and returns a value as output.
In contrast to HTTP, a websocket has more than one type of event, and more than one type of reaction. A message from the client need not generate a reaction from the server and vice versa. The open and close events are also often significant for setting up communication between multiple clients.
So something that is suitable to represent HTTP may not be the right abstraction to represent a websocket. In the simplest case, a Ring handler is a function that takes the request as an argument and returns a response. The design of asynchronous handlers builds upon that, allowing middleware to be written that can handle both synchronous and asynchronous handlers. However, Websockets have no such compatibility concerns, so we can consider their design in isolation.
Protocols have a few advantages that led to their choice in the specification.
First, protocols are most similar to the design of websockets we see in Javascript and other languages. While familiarity is not necessarily a critical consideration, it is a nice bonus.
Second, protocols have more efficient dispatch. Again, this is not a major consideration as dispatching off a map key isn't that inefficient, but it is a benefit.
Third, a function can satisfy a protocol, so we can still use functions that satisfy the listener protocol outlined in the specification. A function could be simpler interface that targets the most common use-cases of handling message and close events. For example:
(fn [socket message]
(ws/send socket message)
(ws/close socket))
In this case the message is either a string, byte array, or nil
if
the socket is being closed.
Forth, we can partially implement a protocol via reify:
(reify ws/Listener
(on-open [_ socket]
(ws/send socket "Hello")
(ws/close socket 1000)))
Other methods on the protocol will generate an AbstractMethodError
exception when called, which can be ignored by the adapter. When
dealing with a function that handles events, we place the burden of
ignoring irrelevant events on the developer.
Fifth, by using a protocol for output, we can write low-level methods
that can be augmented by higher level functions. For example, a
close
function can be written that has useful defaults when called
with one argument:
(defn close
([socket]
(close socket 1000 "Normal Closure"))
([socket code reason
(send-close socket code reason)))
Some implementations of websocket listeners distinguish between binary and text messages. In Clojure this might be implemented as:
(defprotocol Listener
(on-binary [listener socket bytes])
(on-text [listener socket string])
...)
We might also do the same for sending messages:
(defprotocol Socket
(send-binary [socket bytes])
(send-text [socket string])
...)
The advantage of this approach is that it simplifies implementation; an adapter that supports websockets does not need to dispatch off the type of the message.
However, by using the more generic on-message
and send-message
methods we open up the possibility of listener middleware. We can wrap
a listener in another listener that adds functionality, such as
automatically serializing data structures to and from JSON or
edn:
(wrap-edn-listener
(reify ws/Listener
(on-open [_ socket]
(ws/send socket {:pure "data"})
(ws/close))))
In order to distinguish a HTTP response from a websocket response, we use a distinct key:
{:ring.websocket/listener
(reify ws/Listener
(on-open [_ socket]
(ws/send socket "Hello")
(ws/close socket 1000)))}
We use this rather than checking for the protocol itself, as a common type (such as a function) may satisfy several protocols. Using a unique key is unambiguous, and keeps the return type (a map) the same
Websocket support was introduced in the Servlet 3.1 specification. Unfortunately Java servlets emphasize ease of use over a simple design, making it hard to write good tooling around them.
That said, there's a lot we can do to facilitate integration in Ring,
and the Ring 2.0 specification is flexible enough to allow websocket
integration in even the most restrictive environments. A request map
needs only the method to be valid, and a request that returns a
websocket must always be a GET
. Therefore, at minimum, a websocket
request can be:
#:ring.request{:method :get}
Of course ideally we should also know the path, and it would be nice to know the headers involved as well. However, having a valid minimal base is useful for broader integration.
There are good tools in Clojure for representing a channel or stream of messages, such as [core.async][], or Zach Tellman's [Manifold][].
Rather than building a websocket listener directly, we might create a library that constructs the listener given two core.async channels, or a single Manifold duplex stream.
(fn [request]
(let [in (a/chan)
out (a/chan)]
(go (let [name (<! in)]
(>! out (str "Hello " name))
(a/close! in)
(a/close! out)))
#::ws{:listener (websocket-chan-listener in out)}))
We might go even further and abstract the entire handler:
(websocket-chan-handler
(fn [in out]
(go (let [name (<! in)]
(>! out (str "Hello " name))
(a/close! in)
(a/close! out)))))
The protocols that Ring presents give us a common basis to build on, but the interface used by the end developer may look something more like the above examples.
HTTP/2 introduced push promises, a predictive optimisation that allows HTTP responses to be sent to the client before they are requested. Support for push promises was introduced in Java Servlets 4.0.
The implementation for Ring 2 reuses the respond
function passed to
asynchronous handlers. Behind the scenes a push request and its
corresponding response are passed to the client, but for the handler
we need only specify the request, since the response can be found by
calling the handler again.
To differentiate the push request from a normal request, the
ring.push
namespace is used.
The servlet implementation uses a PushBuilder object. Fortunately this fits in well with Ring's design, allowing for easy integration with existing servlets.
It's worth noting that nginx, a common proxy for Ring applications, does not support push promises.
Can you improve this documentation? These fine people already did:
James Reeves & zefuEdit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close