An HTTP server in Java, part 3
In my previous two posts, I walked through the development of a functional mini-HTTP server in Java that I use to mock out external calls when I want to emulate specific hard-to-reproduce events like server failures. Although it covers most of the functionality that I need right now (while still falling far short of being a specification compliant HTTP server), there are at least two more features that are useful for testing specific scenarios: cookie handling and persistent connections.
Cookies
Cookies allow HTTP servers to exhibit stateful behavior (with respect to a particular client) while not actually storing any state, allowing each network request to be serviced independently. Conceptually, cookies are simple - the server sends the client (i.e. a browser) a piece of information to "remember" and the client is responsible for sending that piece of data back on each subsequent request.
If you've even dabbled in HTTP, it should come as no surprise that this is implemented in HTTP headers:
the server should send a response header called Set-Cookie
and the client should parse it
and then respond with its own request header Cookie
. The value itself is sort of arcane;
the value of the Set-Cookie
is a comma-delimited list of key-value pairs which themselves
each include metadata about the applicability of the cookie.
Strictly speaking, then, since the implementation of my mini-HTTP server provides handlers access to
request and response headers, the server implementation doesn't actually have to change to support
cookies. I could just push all the responsibility down to the individual handlers, but since Cookie
behavior follows some fairly standard patterns, it's worth just doing it once. Basic cookie support
could be added by including a line in the form:
In the response handler and then including code such as:
response.addHeader("Set-Cookie", "name=value");
Assuming that only one cookie was in effect. Whereas the responder can include as many
request.getHeader("Cookie").split("=")[1];
Set-Cookie
headers as it cares to, the requester will generally only include one
corresponding Cookie
header in the request with all of the cookie values concatenated
together into one. So, if the response included the following headers:
The next request will have the consolidated header:
Set-Cookie: abc=123
Set-Cookie: def=456
Set-Cookie: ghi=789
One limitation of the server I've developed so far, though, is that there can only be one instance
of each header value per response, which is a problem for cookies because multiple cookies require
multiple headers each named
Cookie: abc=123; def=456; ghi=789
Set-Cookie
. In listing 1 below, then, I'll expand the
Response
class to handle multiple repeated headers.
public class Response {
private OutputStream out;
private int statusCode;
private String statusMessage;
private Map<String, List<String>> headers = new HashMap<String, List<String>>();
private String body;
public Response(OutputStream out) {
this.out = out;
}
public void setResponseCode(int statusCode, String statusMessage) {
this.statusCode = statusCode;
this.statusMessage = statusMessage;
}
public void addHeader(String headerName, String headerValue) {
List<String> headerValues = this.headers.get(headerName);
if (headerValues == null) {
headerValues = new ArrayList<String>();
this.headers.put(headerName, headerValues);
}
headerValues.add(headerValue);
}
public void addBody(String body) {
addHeader("Content-Length", Integer.toString(body.length()));
this.body = body;
}
public void send() throws IOException {
addHeader("Connection", "Close");
out.write(("HTTP/1.1 " + statusCode + " " + statusMessage + "\r\n").getBytes());
for (String headerName : headers.keySet()) {
Iterator<String> headerValues = headers.get(headerName).iterator();
while (headerValues.hasNext()) {
out.write((headerName + ": " + headerValues.next() + "\r\n").getBytes());
}
}
out.write("\r\n".getBytes());
if (body != null) {
out.write(body.getBytes());
}
}
}
Here I've changed the Map<String, String>
into a
Map<String, List<String>>
— singular header values are just lists of
length one now. With this change, I can append as many cookie values as I care to.
However, the Set-Cookie
header is still more complex than a simple name/value pair. There
are seven pieces of optional metadata that can be included with each cookie. To include them, the
sender should append a semicolon after the cookie specification and then include the metadata element.
Of the seven, all but two accept additional parameters. To save some headache for the handler writer,
then, it makes sense to create a dedicated class to specify the cookie. This is illustrated in listing
2.
public class Cookie {
private String name;
private String value;
private Date expires;
private Integer maxAge;
private String domain;
private String path;
private boolean secure;
private boolean httpOnly;
private String sameSite;
public Cookie(String name,
String value,
Date expires,
Integer maxAge,
String domain,
String path,
boolean secure,
boolean httpOnly,
String sameSite) {
this.name = name;
this.value = value;
this.expires = expires;
this.maxAge = maxAge;
this.domain = domain;
this.path = path;
this.secure = secure;
this.httpOnly = httpOnly;
this.sameSite = sameSite;
}
public String toString() {
StringBuffer s = new StringBuffer();
s.append(name + "=" + value);
if (expires != null) {
SimpleDateFormat fmt = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss");
s.append("; Expires=" + fmt.format(expires) + " GMT");
}
if (maxAge != null) {
s.append("; Max-Age=" + maxAge);
}
if (domain != null) {
s.append("; Domain=" + domain);
}
if (path != null) {
s.append("; Path=" + path);
}
if (secure) {
s.append("; Secure");
}
if (httpOnly) {
s.append("; HttpOnly");
}
if (sameSite != null) {
s.append("; SameSite=" + sameSite);
}
return s.toString();
}
}
In short - the Expires
and Max-Age
parameters specify how long the cookie
should be remembered; if omitted, the cookie should not be remembered past the current browser session.
The domain and path indicate which subset of the current server the cookie should be returned to:
if omitted, the cookie should be returned by the client on every request to the domain that the
cookie came from. Of course, for security reasons, no compliant browser will allow a server to set
cookies outside its domain, but the server can restrict the cookie to subdomains. Secure and HttpOnly
indicate that the cookie should only be returned when the connection is an HTTPS connection or when
the request is made by the browser (as opposed to a JavaScript XMLHttpRequest
),
respectively, and SameSite
can be set to Strict
or Lax
to
indicate whether or not the cookie should only be sent if the request originated from the cookie's own
site. These last three are security options to guard against XSS and CSRF attacks.
With this class, the handler can instantiate a Cookie
instance and invoke
addHeader
with Set-Cookie
and the string value of the cookie itself, but
the following convenience method in Response
makes this clearer:
public void addCookie(Cookie cookie) {
addHeader("Set-Cookie", cookie.toString());
}
As I pointed out before, the client is responsible for passing back cookie values — the
specification requires that all cookies be transmitted in a single Cookie
header.
The cookie header value is a semicolon (;) delimited list of
name-value pairs, each of which is a cookie that was sent back by the server in some prior request.
The metadata is not returned by the client; all of the metadata values indicate to the client how and
when to return the cookie so are not meaningful to the server. Although I have a Cookie
class, there's no good reason to parse the request cookies into instances of it since they're just
name/value pairs; however, I should go ahead and parse the name/value pairs themselves and make them
easily accessible. I'll modify the header parsing routine of Request.parse
to handle
the Cookie
header specially as shown in listing 3.
String name = headerLine.substring(0, separator);
String value = headerLine.substring(separator + 2);
headers.put(name, value);
if ("Cookie".equals(name)) {
parseCookies(value);
}
Assuming conforming input, the cookie parsing is pretty simple:
public class Request {
...
private Map<String, String> cookies = new HashMap<String, String>();
private void parseCookies(String cookieString) {
String[] cookiePairs = cookieString.split("; ");
for (int i = 0; i < cookiePairs.length; i++) {
String[] cookieValue = cookiePairs[i].split("=");
cookies.put(cookieValue[0], cookieValue[1]);
}
}
public String getCookie(String cookieName) {
return cookies.get(cookieName);
}
Connection: Keep-Alive
One last change. I've actually been lying this whole time about this server: I keep saying it's
HTTP/1.1, but the way it's coded, it isn't. The biggest omission here is that I'm not honoring the
Connection: keep-alive
header. In the first revision of HTTP (0.9 if you're counting,
but continuing on to 1.0), the actual socket connection was closed by the client once the request was
sent and the response was received. Since almost every useful HTTP interchange involves several
back-to-back requests to the same origin server, HTTP/1.1 introduced the Connection: Keep-Alive
header that the client could set to indicate that it wanted to be able to send another HTTP request
right after the last one had been sent (this is why Content-Length
and chunked
transfer-encoding are so important - there's no other way for the two parties to tell when the
transmission is complete otherwise). For example, if the browser downloads a page with embedded
img
tags, it can immediately begin requesting those images over the same connection while
it's still parsing the response — in fact, it can request the next image before the first
one has been fully downloaded. The client indicates that it is willing and able to do this by sending
a Connection: keep-alive
header and the server responds with either a corresponding
keep-alive
header or a Connection: close
header indicating that it can't
do that. So far, that's what my server does, and the browsers I tested against degrade gracefully
and dutifully open new connections. It's not too much of a stretch to honor keep alives, though.
The socket that handles the connection is opened and processed in HttpServer.run
. I can
add a loop around the request and response handler there and wait until the client indicates that the
connection should be closed. The HTTP/1.1 specification actually mandates that, if the client advertises
that it understands HTTP/1.1 but omits the Connection
header, the connection should default
to keep alive. You might expect that the client would send a flurry of requests followed by a final
request with Connection: Close
, but in actual practice most clients just leave the connection
open and leave it to the server to time it out after a period of inactivity, so it's important for the
implementation to take this into account and terminate the connection after a pause in communication.
class SocketHandler implements Runnable {
...
public void run() {
BufferedReader in = null;
OutputStream out = null;
try {
socket.setSoTimeout(10000);
in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
out = socket.getOutputStream();
boolean done = false;
while (!done) {
Request request = new Request(in);
try {
if (!request.parse()) {
response(500, "Unable to parse request", out);
return;
}
} catch (SocketTimeoutException e) {
break;
}
if ("close".equalsIgnoreCase(request.getHeader("connection"))) {
done = true;
}
...
response.send(request.getHeader("connection"));
Now, as coded, I read the request, send the response in its entirety, and then loop back around and look for a subsequent request. To
properly support HTTP "pipelining", I actually ought to look for the next request concurrently while sending the response. I haven't
done this here, but it's easy enough to wrap the last half of the run
method in a Runnable
and spawn another
thread to do so — out
and request
have to be declared final (in Java versions prior to 1.8) and a bit
of error handling has to be shuffled around, but otherwise it's easy to support. I tried it and didn't see much functional or
performance difference, but it's something to be aware of if you're looking at HTTP implementations.
Add a comment:
I like that you tackle stuff where most serious "software engineers" automatically shout something about re-inventing something and then point you to libraries weighing hundreds of megabytes.
In fact, I have written my own mini-HTTP server -- which BTW is called exactly that, because my own libraries are called "mini" to remind myself to stay minimal). I will upgrade with some of the features you added (keep-alive and maybe cookie handling).
I created that server mainly to be able to wrap code that relies on heavy libraries in the server, to reduce JAR dependencies of my application code.
For example, I have a library that extracts metadata from a bunch of files. It relies on dozens of fat libraries for decoding PDF, Microsoft Office, various image and video formats, etc. -- definitively stuff where I don't want to reinvent the heptadecagon. I just POST my files, get the metadata as JSON, and it works like a charm. Maybe a little slower than without localhost connections, but my application code is lean and mean and clean, and to me, that weighs up small performance benefits.
This article (and previous articles 077 and 076) is great! Thanks for your effort.
There's a minor typo in Listing 3:
```
public void addCookie(Cooke cookie) {
addHeader("Set-Cookie", cookie.toString());
}
```
Cooke should be Cookie.