This post is the conclusion of HTTP server from scratch. Previous post about modularization can be found here.
In this post we will make our webserver somewhat more configurable. We will add one more switch to the server.properties
to point to a external directory that will be used to serve the documents from. Since it is a good idea to support compression of the documents that we send, we will add support to gzip compression and finally, we will update our build configuration to generate a property distribution.
web.root
The new configuration property that we will add is the web.root
, default setting will point to server.root
, when running it outside of the IDE we will be able to use -Dweb.root=/path/to/web/root
.
Before we can do that we have add one more HTTP status code 400 Bad Request
. According to the HTTP spec, the resource the user-agent can request should be a absolute path and not a relative path. As a defensive approach we will sanitize the incoming request to contain only absolute paths, otherwise we will return with HTTP status code 400 Bad Request
.
So far we have been only serving a single file, index.html
from the root of server.root
, but a web server should be able to serve other static documents from root and sub-directories. Typically all CSS documents will be located in css
and JavaScript in js
directories.
Improved logging
We will improve our logging to include the requested resource in the output. To do that we will add an additional String argument resource
to the method log
. Now we will know what was requested and its corresponding status.
Resolving the requested resource
To help resolve the requested resource, we will add a helper method resolveResource
, which will take a String and return a String. In this method we will walk the requested resource path and skipping relative paths like .
and ..
.
private String resolveResource(String requestedPath) { | |
Path resolvedPath = FileSystems.getDefault().getPath(""); | |
Path other = FileSystems.getDefault().getPath(requestedPath); | |
for (Path path : other) { | |
if (!path.startsWith(".") && !path.startsWith("..")) { | |
resolvedPath = resolvedPath.resolve(path); | |
} | |
} | |
if (resolvedPath.startsWith("")) { | |
resolvedPath = resolvedPath.resolve(INDEX_HTML); | |
} | |
return resolvedPath.toString(); | |
} |
Other helper methods
We need other helper methods which will parse the request headers, determine if the User-Agent can support response compression and get the correct type of OutputStream to which we will write the response.
private Map<String, String> parseRequestHeaders(BufferedReader in) throws IOException { | |
Map<String, String> headers = new HashMap<>(); | |
// parse the first line. | |
String header = in.readLine(); | |
StringTokenizer tokenizer = new StringTokenizer(header); | |
headers.put(METHOD, tokenizer.nextToken().toUpperCase()); | |
headers.put(RESOURCE, tokenizer.nextToken().toLowerCase()); | |
headers.put(PROTOCOL, tokenizer.nextToken()); | |
// Rest of the headers | |
String line; | |
while ((line = in.readLine()) != null && !line.isEmpty()) { | |
int idx = line.indexOf(':'); | |
if (idx > 0) { | |
headers.put(line.substring(0, idx).toLowerCase(), line.substring(idx + 1).trim()); | |
} | |
} | |
return headers; | |
} | |
private OutputStream getDataOutputStream(Map<String, String> requestHeaders, OutputStream outputStream) | |
throws IOException { | |
String acceptedEncoding = requestHeaders.getOrDefault(Http.Header.ACCEPT_ENCODING, ""); | |
if (acceptedEncoding.contains("gzip")) { | |
return new GZIPOutputStream(outputStream); | |
} | |
return new BufferedOutputStream(outputStream); | |
} | |
private String getContentEncoding(Map<String, String> requestHeaders) { | |
String acceptedEncoding = requestHeaders.getOrDefault(Http.Header.ACCEPT_ENCODING, ""); | |
return acceptedEncoding.contains(GZIP) ? GZIP : null; | |
} | |
private void writeResponseHeaders(PrintWriter out, String protocol, String status, String mimeType, String date, | |
int length, String contentEncoding) { | |
out.println(protocol + status); | |
out.println(SERVER + config.getProperty(SERVER_VERSION_PARAM)); | |
out.println(DATE + date); | |
if (contentEncoding != null) | |
out.println(CONTENT_ENCODING + contentEncoding); | |
out.println(CONTENT_TYPE + mimeType + ";charset=\"utf-8\""); | |
out.println(CONTENT_LENGTH + length); | |
out.println(CONNECTION + "close"); | |
out.println(); | |
out.flush(); | |
} |
We will also update the existing helper method, writeResponseHeaders
to take an additional argument describing the MIME type of the response. Earlier we had hardcoded the MIME type as text/html
. To help determine the MIME type of the document, Java already has a static method probeContentType
in Files class.
Finally we are in a position to refactor the run
method to get to what we set out to achieve in the beginning of the post.
public void run() { | |
OutputStream dataOut = null; | |
try (BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream())); | |
PrintWriter out = new PrintWriter(client.getOutputStream())) { | |
Map<String, String> requestHeaders = parseRequestHeaders(in); | |
String method = requestHeaders.get(METHOD); | |
String resource = requestHeaders.get(RESOURCE); | |
String protocol = requestHeaders.get(PROTOCOL); | |
String status; | |
String resolvedResourcePath; | |
File outputFile; | |
if (resource.contains("./") || resource.contains("../")) { | |
status = Http.Status.BAD_REQUEST; | |
outputFile = new File(this.serverRoot, BAD_REQUEST); | |
} else if (Http.Method.GET.equals(method)) { | |
resolvedResourcePath = resolveResource(resource); | |
outputFile = new File(this.webRoot, resolvedResourcePath); | |
if (!outputFile.exists()) { | |
status = Http.Status.NOT_FOUND; | |
outputFile = new File(this.serverRoot, NOT_FOUND); | |
} else { | |
if (outputFile.isDirectory()) { | |
outputFile = new File(outputFile, INDEX_HTML); | |
} | |
if (outputFile.exists()) { | |
status = Http.Status.OK; | |
} else { | |
status = Http.Status.NOT_FOUND; | |
outputFile = new File(this.serverRoot, NOT_FOUND); | |
} | |
} | |
} else { | |
outputFile = new File(this.serverRoot, NOT_IMPLEMENTED); | |
status = Http.Status.NOT_IMPLEMENTED; | |
} | |
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("GMT")); | |
String date = now.format(HTTP_FORMATTER); | |
String mimeType = Files.probeContentType(outputFile.toPath()); | |
byte[] data = readFile(outputFile); | |
// write the headers | |
writeResponseHeaders(out, protocol, status, mimeType, date, data.length, getContentEncoding(requestHeaders)); | |
// write the outputFile contents | |
dataOut = getDataOutputStream(requestHeaders, client.getOutputStream()); | |
dataOut.write(data, 0, data.length); | |
if (dataOut instanceof GZIPOutputStream) { | |
((GZIPOutputStream) dataOut).finish(); | |
} else { | |
dataOut.flush(); | |
} | |
// log the request | |
log(client.getInetAddress(), date, method, status, requestHeaders.getOrDefault(UA, ""), resource); | |
} catch (IOException e) { | |
System.out.println(e.getMessage()); | |
} finally { | |
if (dataOut != null) { | |
try { | |
dataOut.close(); | |
} catch (IOException ex) { | |
System.out.println(ex.getMessage()); | |
} | |
} | |
} | |
} |
We will not know the response type until we have parsed the headers, so we will not be able to use the try-with-resources
for the dataOut
OutputStream. So we will have to do it the old fashioned way. After parsing the headers we determine how to respond to the request the corresponding Status code. Finally, we will create either a BufferedOutputStream or GZIPOutputStream depending on what the User-Agent accepts.
Building a distribution
To let gradle know that we have to package the server
directory, which is our default server.root
, we will update our build.gradle
and add the following.
distributions { | |
main { | |
contents { | |
from 'server', { | |
into 'bin/server' | |
} | |
} | |
} | |
} |
Now if execute gradle distTar
or gradle distZip
we will get a nice distribution ready tar or zip file.
Code so far
Code for this post and the rest of the posts in the series can be found on GitHub at https://github.com/cx0der/http-server. Branch step-3
contains the modifications from this post.