1. Overview

In this tutorial, we’re going to implement a simple upper-casing server over HTTP with Netty, an asynchronous framework that gives us the flexibility to develop network applications in Java.

2. Server Bootstrapping

Before we start, we should be aware of the basics concepts of Netty, such as channel, handler, encoder, and decoder.

Here we’ll jump straight into bootstrapping the server, which is mostly the same as a simple protocol server:

public class HttpServer {

    private int port;
    private static Logger logger = LoggerFactory.getLogger(HttpServer.class);

    // constructor

    // main method, same as simple protocol server

    public void run() throws Exception {
        ...
        ServerBootstrap b = new ServerBootstrap();
        b.group(bossGroup, workerGroup)
          .channel(NioServerSocketChannel.class)
          .handler(new LoggingHandler(LogLevel.INFO))
          .childHandler(new ChannelInitializer() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                ChannelPipeline p = ch.pipeline();
                p.addLast(new HttpRequestDecoder());
                p.addLast(new HttpResponseEncoder());
                p.addLast(new CustomHttpServerHandler());
            }
          });
        ...
    }
}

So, here only the childHandler differs as per the protocol we want to implement, which is HTTP for us.

We’re adding three handlers to the server’s pipeline:

  1. Netty’s HttpResponseEncoder – for serialization
  2. Netty’s HttpRequestDecoder – for deserialization
  3. Our own CustomHttpServerHandler – for defining our server’s behavior

Let’s look at the last handler in detail next.

3. CustomHttpServerHandler

Our custom handler’s job is to process inbound data and send a response.

Let’s break it down to understand its working.

3.1. Structure of the Handler

CustomHttpServerHandler extends Netty’s abstract SimpleChannelInboundHandler and implements its lifecycle methods:

public class CustomHttpServerHandler extends SimpleChannelInboundHandler {
    private HttpRequest request;
    StringBuilder responseData = new StringBuilder();

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
       // implementation to follow
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

As the method name suggests, channelReadComplete flushes the handler context after the last message in the channel has been consumed so that it’s available for the next incoming message. The method exceptionCaught is for handling exceptions if any.

So far, all we’ve seen is the boilerplate code.

Now let’s get on with the interesting stuff, the implementation of channelRead0.

3.2. Reading the Channel

Our use case is simple, the server will simply transform the request body and query parameters, if any, to uppercase. A word of caution here on reflecting request data in the response – we are doing this only for demonstration purposes, to understand how we can use Netty to implement an HTTP server.

Here, we’ll consume the message or request, and set up its response as recommended by the protocol (note that RequestUtils is something we’ll write in just a moment):

if (msg instanceof HttpRequest) {
    HttpRequest request = this.request = (HttpRequest) msg;

    if (HttpUtil.is100ContinueExpected(request)) {
        writeResponse(ctx);
    }
    responseData.setLength(0);            
    responseData.append(RequestUtils.formatParams(request));
}
responseData.append(RequestUtils.evaluateDecoderResult(request));

if (msg instanceof HttpContent) {
    HttpContent httpContent = (HttpContent) msg;
    responseData.append(RequestUtils.formatBody(httpContent));
    responseData.append(RequestUtils.evaluateDecoderResult(request));

    if (msg instanceof LastHttpContent) {
        LastHttpContent trailer = (LastHttpContent) msg;
        responseData.append(RequestUtils.prepareLastResponse(request, trailer));
        writeResponse(ctx, trailer, responseData);
    }
}

As we can see, when our channel receives an HttpRequest, it first checks if the request expects a 100 Continue status. In that case, we immediately write back with an empty response with a status of CONTINUE:

private void writeResponse(ChannelHandlerContext ctx) {
    FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, CONTINUE, 
      Unpooled.EMPTY_BUFFER);
    ctx.write(response);
}

After that, the handler initializes a string to be sent as a response and adds the request’s query parameters to it to be sent back as-is.

Let’s now define the method formatParams and place it in a RequestUtils helper class to do that:

StringBuilder formatParams(HttpRequest request) {
    StringBuilder responseData = new StringBuilder();
    QueryStringDecoder queryStringDecoder = new QueryStringDecoder(request.uri());
    Map<String, List<String>> params = queryStringDecoder.parameters();
    if (!params.isEmpty()) {
        for (Entry<String, List<String>> p : params.entrySet()) {
            String key = p.getKey();
            List<String> vals = p.getValue();
            for (String val : vals) {
                responseData.append("Parameter: ").append(key.toUpperCase()).append(" = ")
                  .append(val.toUpperCase()).append("\r\n");
            }
        }
        responseData.append("\r\n");
    }
    return responseData;
}

Next, on receiving an HttpContent, we take the request body and convert it to upper case:

StringBuilder formatBody(HttpContent httpContent) {
    StringBuilder responseData = new StringBuilder();
    ByteBuf content = httpContent.content();
    if (content.isReadable()) {
        responseData.append(content.toString(CharsetUtil.UTF_8).toUpperCase())
          .append("\r\n");
    }
    return responseData;
}

Also, if the received HttpContent is a LastHttpContent, we add a goodbye message and trailing headers, if any:

StringBuilder prepareLastResponse(HttpRequest request, LastHttpContent trailer) {
    StringBuilder responseData = new StringBuilder();
    responseData.append("Good Bye!\r\n");

    if (!trailer.trailingHeaders().isEmpty()) {
        responseData.append("\r\n");
        for (CharSequence name : trailer.trailingHeaders().names()) {
            for (CharSequence value : trailer.trailingHeaders().getAll(name)) {
                responseData.append("P.S. Trailing Header: ");
                responseData.append(name).append(" = ").append(value).append("\r\n");
            }
        }
        responseData.append("\r\n");
    }
    return responseData;
}

3.3. Writing the Response

Now that our data to be sent is ready, we can write the response to the ChannelHandlerContext:

private void writeResponse(ChannelHandlerContext ctx, LastHttpContent trailer,
  StringBuilder responseData) {
    boolean keepAlive = HttpUtil.isKeepAlive(request);
    FullHttpResponse httpResponse = new DefaultFullHttpResponse(HTTP_1_1, 
      ((HttpObject) trailer).decoderResult().isSuccess() ? OK : BAD_REQUEST,
      Unpooled.copiedBuffer(responseData.toString(), CharsetUtil.UTF_8));
    
    httpResponse.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");

    if (keepAlive) {
        httpResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 
          httpResponse.content().readableBytes());
        httpResponse.headers().set(HttpHeaderNames.CONNECTION, 
          HttpHeaderValues.KEEP_ALIVE);
    }
    ctx.write(httpResponse);

    if (!keepAlive) {
        ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
    }
}

In this method, we created a FullHttpResponse with HTTP/1.1 version, adding the data we’d prepared earlier.

If a request is to be kept-alive, or in other words, if the connection is not to be closed, we set the response’s connection header as keep-alive. Otherwise, we close the connection.

4. Testing the Server

To test our server, let’s send some cURL commands and look at the responses.

Of course, we need to start the server by running the class HttpServer before this.

4.1. GET Request

Let’s first invoke the server, providing a cookie with the request:

curl http://127.0.0.1:8080?param1=one

As a response, we get:

Parameter: PARAM1 = ONE

Good Bye!

We can also hit http://127.0.0.1:8080?param1=one from any browser to see the same result.

4.2. POST Request

As our second test, let’s send a POST with body sample content:

curl -d "sample content" -X POST http://127.0.0.1:8080

Here’s the response:

SAMPLE CONTENT
Good Bye!

This time, since our request contained a body, the server sent it back in uppercase.

5. Conclusion

In this tutorial, we saw how to implement the HTTP protocol, particularly an HTTP server using Netty.

HTTP/2 in Netty demonstrates a client-server implementation of the HTTP/2 protocol.

As always, source code is available over on GitHub.