[HttpServer series] Static File server, and Logging

As we delve deeper into the series, today we attempt to write a Static file server, and wire it up to serve css and html pages.

As with the other posts, you can see the merge request for the code change, the repository for the entire code or the series for all the articles on the journey.

The first thing that needed to be done is write a handler for static files. Here is the structure of the StaticFileHandler:

public RouteResponse handle(RouteRequest req) {
    // only support get calls
    if (req.method() != RequestMethod.GET) {
      // 404: Not Found is automatically handled
      return null;
    }

    String uriPath = computeUriPath(req.uri().getPath());
    // never allow access to any directory
    var path = Paths.get(this.folder, uriPath);

    // if the file doesn't exist, or is a directory, return null
    if (!Files.isRegularFile(path)) {
      // 404: Not Found is automatically handled
      return null;
    }

    // get content mime time based on file name
    try {
      var contentType = Files.probeContentType(path);
      if (contentType == null) {
        throw new IllegalArgumentException("Unable to determine content type for file: " + path);
      }
      // read the file and return in the response
      var fileContent = new String(Files.readAllBytes(path));
      return new RouteResponse(200, contentType, fileContent);
    } catch (IOException e) {
      LoggerConfig.getApplicationLogger().log(Level.SEVERE, "Error while accessing file: {0} ", path);
      LoggerConfig.getApplicationLogger().log(Level.SEVERE, "Error Message: {0} ", e.getLocalizedMessage());
      LoggerConfig.getApplicationLogger().log(Level.SEVERE, "Error Trace: {0} ", e.getStackTrace());

      return new RouteResponse(403, ContentType.CONTENT_TYPE_TEXT.getValue(), "Forbidden");
    }
  }

The notable pieces here are:

  • the handling of only the GET verb

  • handling of file existence, and only a file, not a directory

  • setting the content type of the response, based on the file name

  • returning a 403 / Forbidden, when there is an exception accessing the file

The Router's sendResponse now handles more cases, ensuring a proper cleanup, and a content type, if there is a body specified in the response:

 private void sendResponse(HttpExchange exchange, RouteResponse response) throws IOException {
        if (response.body() != null && !HeaderHelper.hasContentType(response.headers())) {
            throw new IllegalArgumentException("Need a content type if response body is specified");
        }

        try (var os = exchange.getResponseBody()) {
            int respBodyLen = response.body() == null ? -1 : response.body().getBytes().length;

            exchange.sendResponseHeaders(response.statusCode(), respBodyLen);
            var responseHeaders = exchange.getResponseHeaders();
            // add the headers from the response to the exchange
            responseHeaders.putAll(response.headers());
            if (respBodyLen >= 0) {
                os.write(response.body().getBytes());
            }
        }
    }

The tests have been updated. This time to handle the missing condition of "Internal server exception", in the RouterTest.

    @Test
    void testInternalServerError() {
        var req = RouterHelper.createGetRequest("/error"); 
        var resp = router.processRequest(req);
        assertEquals(500, resp.statusCode());
        assertEquals(List.of("text/plain"), resp.headers().get("Content-Type"));
    }

The route for handling /error in the test is just a simple:

config.addHandler("/error", (req) -> { 
   throw new RuntimeException("Exception for testing."); 
});

The logging usage is slightly unconventional. But first, the setup code:

static void setupGlobalConsoleLogger() {
    // Get the global logger
    Logger globalLogger = Logger.getLogger("");
    // always log info and above for all loggers
    globalLogger.setLevel(Level.INFO);

    // Remove any existing handlers to avoid duplication
    Handler[] handlers = globalLogger.getHandlers();
    for (Handler handler : handlers) {
      globalLogger.removeHandler(handler);
    }

    // Create a console handler and set its log level
    var consoleHandler = new ConsoleHandler();

    consoleHandler.setFilter(f -> true);

    consoleHandler.setFormatter(new Formatter() {
      final SimpleDateFormat loggerDateFormat = new SimpleDateFormat("dd/MMM/yy HH:mm:ss.SSS");
      public String format(LogRecord lr) {
        var message = lr.getMessage();
        var params = lr.getParameters();

        if (params != null && params.length > 0) {
          message = MessageFormat.format(message, params);
        }

        String formattedDate = loggerDateFormat.format(Date.from(lr.getInstant()));

        return String.format("%d::%s::%s::%s::%s::%s%n",
                lr.getLongThreadID(), lr.getLoggerName(), lr.getSourceClassName(), lr.getSourceMethodName(), formattedDate, message);
      }
    });
    // Add the console handler to the global logger
    globalLogger.addHandler(consoleHandler);

    // setup the specific app loggers after the global settings
    if (RuntimeConfig.getInstance().runMode() == RuntimeConfig.RunMode.PROD) {
      // only log sever message from the debug logs, in prod
      getDebugLogger().setLevel(Level.SEVERE);
    } else {
      getDebugLogger().setLevel(Level.FINE);
    }
  }

When I asked the question: What is the logger caching strategy for java.util.logging framework ? When do the created loggers release memory ? to ChatGPT, the response I got was:

The Logger instances are typically stored using weak references. This means that if there are no strong references to a logger (i.e., it's not actively used), it may be eligible for garbage collection. Weak references allow unused loggers to be automatically released when memory is needed.

This essentially means that I have been doing loggers wrong all this while, but just creating and keeping references at the instance level.

Also, when I looked at the Formatter available for the ConsoleHandler above, I could log the class and method name of the caller. So, there was no need to create loggers that are specific to each class. Instead, I decided on using two kinds of loggers, an Application level logger, and a Debug logger. The idea behind these is to have explicit intent. Anything that goes into the "Application Logger" is intended to always be logged. While the "Debug logger" should be turned on only for debug purposes, unless the debug message is a "SEVERE" in which case it should always be logged. This allows better memory utilisation, and ensures that the printing format is under control.

There is an explicit intent to use only the ConsoleHandler, and no file handler, because I assume that the application will always be deployed with a Container manager like Docker, or a process manager that will redirect the standard console and error streams to its respective handling mechanism.

With this implementation, the server is "functional". It can serve static content, from a specified directory, and also handle requests received over the http flow. It handles the 404, and 500 errors, and cleans up the streams on its way out. So, the client will not be waiting for a response.

Next step will be to explore an authorisation

Did you find this article valuable?

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