Build a Lightweight Java API from Scratch
TL;DR: in this article, I cover how you can create a lightweight Java API which processes an HTTP request without using any external libraries.
This is part one of a two part article - in the second part, I discuss the design patterns I used to make handling requests super simple!
The Brief
Recently, I have been helping my Dad with his project by writing a consumer for his Natural Language Processing Java library, Enguage. I want to expose his
interpret
function - which takes in natural language and creates a response - as an API on the web, to which I can send a POST request.The API didn’t need to be secure or scalable; just easily available as an endpoint to any client so that we could easily create a POC consumer without needing to port the library itself - just make an HTTP request.
An example request would be:
> curl -X POST \ -H "Content-Type: application/json" \ -d '{"sessionId": "<SESSION_ID>", "utterance": "what do I need?"}' \ https://<ENDPOINT>/interpret > you don't need anything
Listening to HTTP Requests and Sending a Simple Response
Server.java
creates the ServerSocket
and listens on port 8080. It also instantiates a new instance of the Enguage
library, to be used within the requests. // Server.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/Server.java package opt.api; import java.io.IOException; import java.net.ServerSocket; import org.enguage.Enguage; import opt.api.utils.RequestHandler; public class Server { static private int port = 8080; static public int port() { return port; } static public void server( int port ) { try (ServerSocket server = new ServerSocket( port, 5 )) { System.out.println("Listening on port " + port() ); while (true) new RequestHandler( server.accept() ).run(); } catch (IOException e) { e.printStackTrace(); } } public static void main( String arg[]) { // Instantiate the library Enguage.set( new Enguage() ); server( port() ); } }
RequestHandler.java
is the entry point for all HTTP requests, it opens with a try-with-resources block which instantiates a BufferReader in
and DataOutputStream out
. We haven’t used in
yet, however we will eventually read the HTTP request for the POST headers and body from it.To send a simple response, we do
out.writeBytes(response)
and then connection.close()
.// RequestHandler.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/utils/RequestHandler.java package opt.api.utils; import java.io.BufferedReader; import java.io.DataOutputStream; import java.io.InputStreamReader; import java.net.Socket; public class RequestHandler extends Thread { private Socket connection; public RequestHandler( Socket conn ) { connection = conn; } private String response = "200 OK"; private String response() { return response; } public void response( String s ) { response = s; } public void run() { try ( BufferedReader in = new BufferedReader( new InputStreamReader(connection.getInputStream()) ); DataOutputStream out = new DataOutputStream(connection.getOutputStream()); ) { String response = "HTTP/1.1 " + response() +"\n" + "Content-Type: text/plain\n" + "Content-Length: " + "<SOME_REPLY_GOES_HERE>".length() + "\n" + "\n" + "<SOME_REPLY_GOES_HERE>\n"; out.writeBytes(response); connection.close(); } catch (Exception e) { e.printStackTrace(); } } }
With these two files, we have enough to send and receive HTTP requests!
You can now build it with
javac Server.java
and run it with java Server
to get Listening on port 8080
. Sending out cURL request, we would get the same response to every request we make - but we’re going to go further to parse the request and send a response which changes with the request!Parsing the HTTP Request
Getting the HTTP String from the BufferedReader
I wrote a class to convert the input stream into a string that I could parse later:
// HttpStringBuilder.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/utils/http/HttpStringBuilder.java package opt.api.utils.http; import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; import java.io.StringReader; public class HttpStringBuilder { public static String build(BufferedReader in) throws IOException { StringBuilder requestBuilder = new StringBuilder(); String headerLine; while ((headerLine = in.readLine()) != null && !headerLine.trim().isEmpty()) { requestBuilder.append(headerLine).append("\r\n"); } String postHeaderCrlf = "\r\n"; requestBuilder.append(postHeaderCrlf); int contentLength = getBodyContentLength(requestBuilder.toString().trim()); char[] buffer = new char[contentLength]; int bytesRead = in.read(buffer, 0, contentLength); if (bytesRead > 0) { requestBuilder.append(buffer, 0, bytesRead); } return requestBuilder.toString().trim(); } // method to extract the Content-Length from the request headers private static int getBodyContentLength(String request) { String[] headerLines = request.split("\r\n"); for (String lineMixedCase : headerLines) { String line = lineMixedCase.toLowerCase(); if (line.toLowerCase().contains("content-length: ")) { int contentLength = Integer.parseInt(line.split("content-length: ")[1]); return contentLength; } } return 0; } }
Parsing the String into Body and Headers
Under the hood, HTTP requests (at layer 7 of the OSI model), is just an ASCII string that looks something like this:
Method Request-URI HTTP-Version Header-field: Header-value Request-Body
Or in our case,
POST /interpret HTTP/1.1 Content-Type: application/json Content-Length: 50 {"sessionId": "1", "utterance": "what do I need?"}
Once we have the string, we can simply extract the headers and parse the body.
I took a very MVP approach to this (as I didn’t want to spend too much time worrying about edge cases I didn’t care about, such as path parameters). If you are adapting this for your own needs, you will need to expand this (or use a library that does it for you). I’m writing this to show that reading an HTTP request is simply a case of string parsing.
Copy this code at your own peril!
// HttpParser.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/utils/http/HttpParser.java package opt.api.utils.http; import java.util.HashMap; import java.util.Map; public class HttpParser { public static Map<String, String> parseHeaders(String httpRequestString) { Map<String, String> parsedData = new HashMap<>(); // Split the request into header and body sections String[] requestParts = httpRequestString.split("\\r\\n\\r\\n", 2); String headerSection = requestParts[0]; // Extract the HTTP method and route from the header String[] headerLines = headerSection.split("\\r\\n"); String firstLine = headerLines[0]; String[] firstLineParts = firstLine.split(" "); String method = firstLineParts[0]; String route = firstLineParts[1].substring(1, firstLineParts[1].length()); String http = firstLineParts[2]; parsedData.put("method", method); parsedData.put("route", route); parsedData.put("http", http); for (int i = 1; i<headerLines.length; i++) { String[] header = headerLines[i].split(": ", 2); if (header.length == 2) { parsedData.put(header[0].trim(), header[1].trim()); } } return parsedData; } public static Map<String, String> parseBody(String httpRequestString) { Map<String, String> parsedData = new HashMap<>(); // Split the request into header and body sections String[] requestParts = httpRequestString.split("\r\n\r\n", 2); String bodySection = requestParts.length > 1 ? requestParts[1] : ""; if (bodySection.trim().length() == 0) return parsedData; Map<String, String> meta = parseHeaders(httpRequestString); String contentType = meta.get("content-type"); if ("application/json".equalsIgnoreCase(contentType)) { // Assuming the body contains JSON data Map<String, String> bodyMap = parseJson(bodySection.trim()); return bodyMap; } return parsedData; } private static Map<String, String> parseJson(String jsonBody) { Map<String, String> bodyMap = new HashMap<>(); int i = 1; // Skip the first '{' while (i < jsonBody.length() - 1) { StringBuilder key = new StringBuilder(); StringBuilder value = new StringBuilder(); while (jsonBody.charAt(i) != ':') { key.append(jsonBody.charAt(i)); i++; } i++; // skip the ':' i++; // skip the whitespace after ':' // Parse the value int braceCount = 0; while (i < jsonBody.length() - 1) { char currentChar = jsonBody.charAt(i); if (currentChar == '{' || currentChar == '[') { braceCount++; } else if (currentChar == '}' || currentChar == ']') { braceCount--; } else if (currentChar == ',' && braceCount == 0) { break; } value.append(currentChar); i++; } // Skip the whitespace after the value and the ',' (if any) i++; while (i < jsonBody.length() && Character.isWhitespace(jsonBody.charAt(i))) { i++; } // Add the key-value pair to the map bodyMap.put( key.toString().toLowerCase().replace("\"", "").trim(), value.toString().replace("\"", "").trim() ); i++; // move to the next character } return bodyMap; } }
RequestHandler Updated
Now that we have the request parsed, my
RequestHandler.java
is updated:// RequestHandler.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/utils/RequestHandler.java package opt.api.utils; ... import opt.api.routing.Router; import opt.api.utils.http.HttpParser; import opt.api.utils.http.HttpStringBuilder; import opt.api.utils.http.HttpException; public class RequestHandler extends Thread { ... private HttpResponseBuilder responseBuilder = new HttpResponseBuilder(); public void run() { try ( BufferedReader in = new BufferedReader( new InputStreamReader(connection.getInputStream()) ); DataOutputStream out = new DataOutputStream(connection.getOutputStream()); ) { String request = HttpStringBuilder.build(in); Map<String, String> head = HttpParser.parseHeaders(request); Map<String, String> body = HttpParser.parseBody(request); String response = responseBuilder.build(head, body); out.writeBytes(response); connection.close(); } catch (Exception e) { e.printStackTrace(); } } }
I have passed the parsed
head
and body
into the responseBuilder.build(head, body)
method which should handle passing back a response. I will link the code to this extraction here, but know that under the hood, it just calls router.handle(head, body)
.I’ll leave it up to you to create
HttpResponseBuilder
for yourself for your own use case.My code for
HttpResponseBuilder
was:// HttpResponseBuilder: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/utils/http/HttpResponseBuilder.java package opt.api.utils.http; import java.util.Map; import opt.api.routing.Router; public class HttpResponseBuilder { private String response = "200 OK"; private String response() {return response;} public void response( String s ) {response = s;} private Router router = new Router(); public String build (Map<String, String> head, Map<String, String> body) { String reply; try { reply = router.handle(head, body); } catch (HttpException e) { reply = e.getErrorMessage(); response(e.getResponse()); } String response = head.get("http") + " " + response() +"\n" + "Content-Type: text/plain\n" + "Content-Length: " + reply.length() + "\n" + "\n" + reply + "\n"; return response; } }
Summary
The above guidelines demonstrate how to write a simple Java API and dispel some of the mystery surrounding frameworks that abstract away much of the low-level complexity of dealing with web requests. This knowledge can help developers understand what happens "under the hood” when frameworks deal with requests.
Next Steps
Read part two to see how I implemented the router design pattern.
Read this article on how I deployed this to Fly.io with BitBucket Pipelines.