[HttpServer Series] The Http Router - from scratch

( Note: You can read the full series at: https://blog.mandraketech.in/series/java-http-server )

Next up in our "No Web Frameworks, No Annotations" series, is the http router.

As we look at the HttpContextHelper, we can clearly see that there are some points of common failure, that every application will need to provide for. The test cases should cover them, but, at this time, they do not, because we only have 2 endpoints, and only Integration tests.

Reminder: The Merge Request for this blog entry will be at the bottom of the page

Lets look at the endpoints already implemented:

class PingHandler implements HttpHandler {

  @Override
  public void handle(HttpExchange he) throws IOException {
    try (var os = he.getResponseBody()) {
        he.sendResponseHeaders(200, 0);
        os.write("pong".getBytes());
    }
  }
}

class RootHandler implements HttpHandler {
  @Override
  public void handle(HttpExchange he) throws IOException {
    try (var os = he.getResponseBody()) {
        he.sendResponseHeaders(404, 0);
        os.write("Not Found !!!".getBytes());
    }
  }
}

These, as you can see, show several points of potential failure when more endpoints are written:

The ResponseBody is an OutputStream , and hence needs to be "closed" before a response can be sent back to the client. If an endpoint fails to close the OutputStream, the thread would return, but the response will never be sent to the client, unless the sendResponseHeaders is set first, and set to 0, so that it uses data streaming ( supported with HTTP/1.1 ). Now, this would mean that the response code should be known upfront. And it just keeps getting more complex from there. Because, the JavaDoc for getResponseBody says that the sendResponseHeaders should be called first. So, you see, we are in a situation where the hander needs to know everything that is to know about the response before it initiates writing anything.

Also, just repeating things from the previous paragraph, because it is an OutputStream, its contents cannot be modified once they are written. This means that every endpoint written will need to ensure that the Output is written only once the endpoint is absolutely sure of the output.

So, we need a way to:

  • Ensure that the sendResponseHeaders is called with the right response code

  • Ensures that the data is written as expected by the handler

  • Ensures that the stream is closed after the handler returns

  • And, if anything does not work, sends out a HTTP 500 "Internal Server Error" so that the client is not kept waiting infinitely.

Some good to haves will be:

  • if the mechanism can fail at startup if there are multiple registrations for the same endpoint, then we know there is a "programming error".

  • the HTTP 400 error, that our generic handler handled, should be handled too.

If the mechanism brings the concepts of functional style of programming in the mix, that would be lovely. The most basic of all being the immutability of the data being handed down to the handler. And the same about the response from the handler.

So, let us set up a prompt and as our GenAI tools if they can help us with this.

Prompt:

I am writing a java application using the jdk.httpserver module. 
I have a handler for the root endpoint that looks like this:

```java
public void handle(HttpExchange he) throws IOException {
    try (var os = he.getResponseBody()) {
        he.sendResponseHeaders(404, 0);
        os.write("Not Found !!!".getBytes());
    }
mark  }
```

I want to extend this to become a full blown Router with the following functionality:
- Ensure that the sendResponseHeaders is called with the right response code

- Ensures that the data is written as expected by the handler

- Ensures that the stream is closed after the handler returns

- And, if anything does not work, sends out a HTTP 500 "Internal Server Error" so that the client is not kept waiting infinitely.

- If the mechanism can fail at startup if there are multiple registrations for the same endpoint, then we know there is a "programming error".

- the HTTP 400 error, that our generic handler handled, should be handled too.

The Router should follow a functional style of programming. 
The most basic of all being the immutability of the data 
being handed down to the handler.
 And the same about the response from the handler.

The response from ChatGPT is a good starting point. So, I will take that and add a Router class to the codebase. Cody does not come close, even with prompts to add some missing functionality.

In order to simplify the Router, and put the programmer more in control, I chose to use the route where all potential programming errors should be checked for, and exceptions thrown, instead of fixing them automatically, or using boilerplate. This will allow me, as a developer, to see the exact behaviour where I need it, and no magic.

I decided on the following architectural decisions:

  • The request sent to the handler should be a "read only" and "immutable" entity. So, I converted the data from HttpExchange into a RouteRequest record object.

  • Unlike other frameworks where response is collected by the infrastructure, I chose to let that come later in the complexity cycle. The current objective is to be able to build a simple app. So, my RouteHandler will just return the response it wants sent back to the client. At this time, the only supported response type is a string, but I am sure we can get a binary stream integrated without too much complexity. But that will not be needed until later, so we will solve it when we get there.

  • The Router handles all cases where the handler has not sent back a response. It just assumes that the handlers did not want to handle it, or could not, and hence it is a 404 Not Found .

  • There is also the case where an exception is thrown from within the handler, and that is a 500 Internal Server Error and logged appropriately by the router.

  • The Router also follows the same semantics as the createContext from the HttpServer documentation, that it will look for the longest match in the route paths, and call that handler. The handler path does not take care of regular expressions, or variables, but all in good time. Start simple is the mantra.

So, here is the core of the Router , which gets exposed as a HttpHandler to be wired into the HttpServer.createContext:

@Override
    public void handle(HttpExchange exchange) throws IOException {
        RouteHandler handler = findHandlerForReq(exchange);
        RouteRequest req = createRouteRequest(exchange);
        RouteResponse response = null;

        if (handler != null) {
            try {
                response = handler.handle(req);
            } catch (Exception e) {
                response = handleInternalError(req, e);
            }
        }

        // if no handling has been done, then its a 404 !!
        if (response == null) {
            response = handleNotFound(req);
        }

        // send the response, and clean up
        sendResponse(exchange, response);
    }

The RouteRequest looks like this:


public record RouteRequest(
            Map<String, List<String>> headers,
            URI uri,
            RequestMethod method,
            InputStream body,
            InetSocketAddress remoteAddress,
            InetSocketAddress localAddress,
            String protocol,
            HttpPrincipal principal) {

        public RouteRequest        {
            var h = new HttpHeader();
            // ensure all headers are lower case
            h.putAll(headers);
            headers = Collections.unmodifiableMap(h);
        }
    }

And the RouteResponse thus:


public record RouteRequest(
            Map<String, List<String>> headers,
            URI uri,
            RequestMethod method,
            InputStream body,
            InetSocketAddress remoteAddress,
            InetSocketAddress localAddress,
            String protocol,
            HttpPrincipal principal) {

        public RouteRequest        {
            var h = new HttpHeader();
            // ensure all headers are lower case
            h.putAll(headers);
            headers = Collections.unmodifiableMap(h);
        }
    }

The HttpContextHelper is not gone. Instead, the App has been updated to wire up the ping handler directly, because the Router already handles all situations:

public App(int httpPort) throws IOException {
        // If running in prod, listen to all connections, even from external
        // in all other cases, only listen to localhost, so only testing !!
        if ( RuntimeConfig.runMode() == RunMode.PROD ) {
            server = HttpServer.create(new InetSocketAddress("0.0.0.0",httpPort), 0);
        } else {
            server = HttpServer.create(new InetSocketAddress("127.0.0.1", httpPort), 0);
        }

        var rootRouter = Router.createRouter((r) -> {
            r.addHandler("/ping", new PingHandler());
        });

        server.createContext("/", rootRouter);
    }

And the PingHandler now looks for the GET method before handling the request:

public class PingHandler implements RouteHandler {

  @Override
  public RouteResponse handle(RouteRequest req) {
    if (req.method() == RequestMethod.GET) {
      return new RouteResponse(200, CONTENT_TYPE_TEXT, "pong");
    }
    return null;
  }
}

I know this could have just been a lambda implementation, but that is not going to be true for most of the complex applications, so I wanted to see the amount of work that will be required.

I am fairly happy with what I see now. Its a simple framework to get started.

The tests have been updated too.

The MergeRequest for this blog is available at: https://gitlab.com/mandraketech/httpserver-based-todo-app/-/merge_requests/7

Next up is a static file server to help us serve some html pages, and other assets.

If you want to read up the rest of the articles in this series, you can look here:

https://blog.mandraketech.in/series/java-http-server

Did you find this article valuable?

Support MandrakeTech Blog by becoming a sponsor. Any amount is appreciated!