Returning chunked responses in Atium
Over the past month I've been doing a lot of work around adding support for chunked Transfer-Encoding in the Atium web server. There's been a lot of functionality added to the parser that supports chunked encoding when receiving HTTP requests. However, today I'm not going to write about the request side for this part of the HTTP/1.1 standard. Today I want to write about the use of chunked encoding when Atium sends responses to client requests. Here is an example of a response that has a chunked body taken from Mozilla's documentation:
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
7\r\n
Mozilla\r\n
11\r\n
Developer Network\r\n
0\r\n
\r\n
In the headers, Transfer-Encoding is sent with a value of “chunked”. This doesn’t have to be the only value for this header. Examples of other values that are supported are “compress”, “deflate”, etc. You can combine multiple, but chunked must be the last one in the list. Currently, Atium only supports the “chunked” value and doesn’t handle decompressing request bodies or compressing response bodies.
The example above seems like a more complicated HTTP request than what you could normally send. And if you are thinking that then you would be right. There is no need to use chunked encoding for such a small body... especially when the size of that body is such an obvious and known value.
Chunked encoding becomes a lot more useful when you have a lot of data to send and the final size is not necessarily known or is, at least, time-consuming to compute. Chunked encoding allows for data to be sent in the body in multiple chunks. This is great for transferring larger files, where reading the entire file into memory, determining its size, and then creating a request or response is not necessarily desirable. A standard way of reading files in most programming languages is through a streaming api (e.g., fread in C, FileStream in C# and InputStream in Java). That way, we can read as much of a file as we want, process it, and then read more later. This maps nicely to the chunked encoding of an HTTP message. We can stream part of a file, include it in a chunk, and then produce more chunks as needed. This sounds very simple, but required some thought on how to implement this behavior on the server side in a way that kept things simple for basic HTTP responses.
Accepting InputStream in HttpResponse objects
An HttpResponse object in Atium looked something like this:
/// File: HttpResponse.java
/// Creator: nathan@nathanieljwright.com
/// Copyright (C) 2024 Nathaniel Wright. All Rights Reserved.
public class HttpResponse {
private final HttpResponseCode m_response;
private final HashMap<String, String> m_headers = new HashMap<>();
private HttpVersion m_version = HttpVersion.Unknown;
private byte[] m_body = null; // byte[] -> Object
}
The m_body field was a byte array, but will now be an opaque object. Any action that returns a String or something similar will be converted into a byte array. Otherwise, it is assumed that the response needs to be chunked and m_body will be an instance of InputStream. This is natural if you consider that some API routes will encode JSON in a response body, while others might have some binary format (e.g., protobuf) and others will want to read and return data from the filesystem. One added difficulty on creating the HTTP response object is that we need to know that chunked encoding will be used before serializing our HTTP response because the Transfer-Encoding header needs to be set. The first step, then, is to create the appropriate HTTP response headers based on the type of object returned from an action.
Atium currently only supports returning ActionResponse objects from an action. This is then converted into an HttpResponse object. You cannot directly return an HttpResponse yourself. ActionResponses are slightly different from an HttpResponse and drastically simplify what a specific action needs to do: return a response object, the type of that object (e.g., json, html, text, jpg, etc.) and a response code.
/// File: HttpServer.java
/// Creator: nathan@nathanieljwright.com
/// Copyright (C) 2024 Nathaniel Wright. All Rights Reserved.
private HttpResponse toResponse(HttpRequest request, ActionResponse response) {
// Set to a positive value if the responseObject has a well-defined size.
long responseLength = -1;
// Response Object can only be one of two types: byte[] or InputStream
// byte[] will be any response body that has a well-defined size.
// InputStream will be any response body that has an unknown size and should therefore be chunked.
Object responseObject = null;
switch (response.getResponse()) {
case null -> {} // null objects are totally possible.
case String str -> {
var bytes = str.getBytes(StandardCharsets.UTF_8);
responseObject = bytes;
responseLength = bytes.length;
}
case byte[] bytes -> {
responseObject = bytes;
responseLength = bytes.length;
}
case InputStream stream -> {
if (request.getVersion() == HttpVersion.Http1) {
// HTTP 1.0 doesn't support chunked encoding.
var bytes = IOUtils.toByteArray(stream);
responseObject = bytes;
responseLength = bytes.length;
} else {
// Chunked encoding is supported
responseObject = stream;
}
}
default -> throw new IllegalStateException("Unexpected value: " + response.getResponse());
}
var httpResponse = new HttpResponse(response.getResponseCode(), request.getVersion(), responseObject);
// Set headers based on the type of object we massaged.
var headers = httpResponse.getHeaders();
if (responseObject != null) {
if (responseLength != -1) {
headers.put(HttpHeader.ContentLength, Long.toString(responseLength));
} else {
headers.put(HttpHeader.TransferEncoding, "chunked");
}
}
headers.put(HttpHeader.ContentType, response.getType().toString());
includeCommonHeaders(httpResponse);
includeVersionSpecificHeaders(httpResponse);
return httpResponse;
}
Then, on writing data to the client socket, the HTTP response's object is checked for the one of two types it can be and then different serialization occurs.
/// File: HttpResponse.java
/// Creator: nathan@nathanieljwright.com
/// Copyright (C) 2024 Nathaniel Wright. All Rights Reserved.
public void toBytes(OutputStream output) throws IOException, InternalServerException {
// The rest of toBytes is omitted for brevity...
switch (m_body) {
case byte[] bytes -> output.write(bytes);
case InputStream stream -> {
if (m_version == HttpVersion.Http11) {
chunkResponseBody(output, stream);
}
}
default -> {
throw new InternalServerException("Unable to process body of response");
}
}
}
private static void chunkResponseBody(OutputStream output, InputStream stream) throws IOException {
var buffer = new byte[8192];
while (true) {
var bytesRead = stream.read(buffer);
// End of stream is reached. For chunked encoding this is denoted by writing 0\r\n.
if (bytesRead == -1) {
bytesRead = 0;
}
var bytesReadAscii = Integer.toString(bytesRead, 16).getBytes(StandardCharsets.US_ASCII);
output.write(bytesReadAscii);
output.write(HttpNewline);
if (bytesRead != 0) {
output.write(buffer, 0, bytesRead);
output.write(HttpNewline);
} else {
output.write(HttpNewline);
break;
}
}
}
Example usage
Allowing for multiple objects types in an action's responses hides a lot of the complexity that the HTTP server handles to appropriately serialize a response. An example of my current usage of this functionality is in loading resources inside this website's .jar file. Every resource that is currently served by this website is stored in the "resources" folder. The loadResource method bellow is a helper method that gets a particular resource as an input stream and returns an ActionResponse with all the relevant file information filled in. This helper method is used extensively to load resources like html, css, js, and image files. As far as I know, there is no way to load these resources as a File object or something similar where the total size in bytes is known for each resource. However, because Atium handles input stream objects smartly now, there is no need to add extra complexity on the library consumer side. You can just load something up as an input stream and chuck it back to the server as a response which feels incredibly nice.
/// File: FileLoader.java
/// Creator: nathan@nathanieljwright.com
/// Copyright (C) 2024 Nathaniel Wright. All Rights Reserved.
public static ActionResponse loadResource(String resourcePath) throws IOException {
var responseType = determineResponseType(resourcePath);
ClassLoader classloader = Thread.currentThread().getContextClassLoader();
var stream = classloader.getResourceAsStream(resourcePath);
if (stream == null) {
throw new NotFoundException(resourcePath);
}
return new ActionResponse(stream, responseType, HttpResponseCode.Ok);
}
/// File: Website.java
/// Creator: nathan@nathanieljwright.com
/// Copyright (C) 2024 Nathaniel Wright. All Rights Reserved.
server.registerAction("/me.jpg", HttpMethod.GET, (ActionContext action)
-> FileLoader.loadResource("img/me-small.jpg"));
In the future, handling File and other object types that could be common return types is certainly something I will consider. Having an opaque Object type that is returned in ActionResponse and is stored in HttpResponse is not ideal, but it does leave the door open for a lot more functionality. It does make it difficult to understand just what is possible as an Atium library user though. If an unsupported object is returned in an ActionResponse then a runtime error will occur when attempting to process that route/action's result and will produce an internal server error response (not ideal...). One way I would solve this in C/C++ is by using a union or std::variant which explicitly documents the types that are supported. In Java, I could create an enum that is required to be set that describes the object's type, but that still allows incorrect usage as the enum value doesn't necessarily have to map to the object provided. Another way is to provide custom classes that describe each possible return type and have a parent class that is accepted as the return value. This wouldn't allow for built-in types like String to be returned though. They would have to be wrapped in these custom classes.