Design Patterns for a Lightweight Java API from Scratch
TL;DR: in this article, I cover the main design patterns I used in creating a lightweight Java API which processes an HTTP request without using any external libraries.
I show how you can use the Template Method Pattern to create a smart separation of concerns when implementing API endpoint routing.
This is part two of a two part article - read part one to see how I parse the HTTP request and send a response!
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
The Starting Point
I have an
HttpResponseBuilder
, which calls router.handle(head, body)
and builds a simple HTTP response to send back to the user with this response.⚙️ See code
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; } }
The HTTP Request Routing Pattern
I wanted to make it really easy to:
- add new endpoints;
- add new HTTP methods to those endpoints.
To do this, I created a routing design pattern for the API to use.
If I want to add a new endpoint with this pattern, I add a new
ActionHandler
to /actions/handlers
with a method of the same name as the HTTP method it should handle.For example, to add a handler for my
/interpret
POST endpoint, all I needed to do was add the following code:// Interpret.java: https://github.dev/martinwheatman/enguageMirror/blob/c7f82b33e7a292582d92015624fe0c3a3ae87564/opt/api/actions/handlers/Interpret.java package opt.api.actions.handlers; import java.util.Map; import opt.api.actions.ActionHandler; public class Interpret extends ActionHandler { protected String post( Map<String, String> head, Map<String, String> body ) { return "<SOME_RESPONSE_HERE>"; } }
If I want to handle the GET method instead, I can just change the name of the method from
post
to get
.Let’s see how I got to this pattern!
Abstract Class ActionHandler
Interpret
extends ActionHandler
which is an abstract class that defines all the possible methods with default implementations for if the concrete implementation of the ActionHandler
doesn’t implement a given HTTP method:// ActionHandler.java: https://github.dev/martinwheatman/enguageMirror/blob/76df376d49c5116191b27c06635bede8aa53751b/opt/api/actions/ActionHandler.java package opt.api.actions; import java.util.Map; import opt.api.utils.http.HttpException; public abstract class ActionHandler { public String handle( Map<String, String> head, Map<String, String> body ) throws HttpException { String method = head.get("method"); Action action = Action.valueOf(method); switch (action) { case GET: return get(head, body); case POST: return post(head, body); case PUT: return put(head, body); case DELETE: return delte(head, body); case PATCH: return patch(head, body); default: throw new HttpException("404 Not Found", "API only supports " + Action.values()); } } protected String get( Map<String, String> head, Map<String, String> body ) throws HttpException { throw new HttpException("404 Not Found", "GET method not implemented"); } protected String post( Map<String, String> head, Map<String, String> body ) throws HttpException { throw new HttpException("404 Not Found", "POST method not implemented"); } protected String put( Map<String, String> head, Map<String, String> body ) throws HttpException { throw new HttpException("404 Not Found", "PUT method not implemented"); } protected String delete( Map<String, String> head, Map<String, String> body ) throws HttpException { throw new HttpException("404 Not Found", "DELETE method not implemented"); } protected String patch( Map<String, String> head, Map<String, String> body ) throws HttpException { throw new HttpException("404 Not Found", "PATCH method not implemented"); } }
I defined an enum for all the possible HTTP methods I wanted to handle:
// Actions.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/actions/Action.java package opt.api.actions; public enum Action { GET, POST, PUT, DELETE, PATCH }
Default implementations for each method are handled here. For example, if we try to do an HTTP DELETE method on
/interpret
, we will get an error response:HTTP/1.1 404 Not Found Content-Type: application/json Content-Length: 29 DELETE method not implemented
If we try an HTTP method that isn’t supported by our API, we also get a graceful error message sent to the client:
HTTP/1.1 404 Not Found Content-Type: application/json Content-Length: 47 API only supports GET, POST, PUT, DELETE, PATCH
When we override our
post
method in Interpret
, we override the default implementation and now we get back our OK response!The good thing about this is that I should “never” need to touch
ActionHandler
again, simply extending it will be enough to get all the error handling.Adding a New Route to the Router
Using the
ActionHandler
, it's easy to add a new HTTP method handler to an existing endpoint - let's now see how we can add an entirely new endpoint!Our
Router
is a very simple class which controls the available endpoints:// Router.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/routing/Router.java package opt.api.routing; import java.util.Map; import opt.api.actions.handlers.Interpret; import opt.api.utils.http.HttpException; public class Router { public String handle ( Map<String, String> head, Map<String, String> body ) throws HttpException { Route route = Route.fromString(head.get("route")); switch (route) { case INTERPRET: return new Interpret().handle(head, body); default: throw new HttpException("404 Not Found", "This API route is not handled"); } } }
We have a switch case on our defined routes, which are all added as an
enum
. As described before, all we need to do is call Interpret().handle(head, body)
and we get all the error handling from the abstract class ActionHandler
.To add a new route, we just need to add a new
ActionHandler
to actions/handlers
, add that to the Route
enum, and handle it in the switch case.Here is the
Route
enum:// Route.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/routing/Route.java package opt.api.routing; public enum Route { INTERPRET("interpret"); private final String routeName; Route(String routeName) { this.routeName = routeName; } public String getRouteName() { return routeName; } // Static method to get the enum value from a string public static Route fromString(String text) { for (Route myEnum : Route.values()) { if (myEnum.routeName.equals(text)) { return myEnum; } } throw new IllegalArgumentException("No enum constant with text: " + text); } }
We have a method
fromString
which takes in a value like "interpret"
and matches it to INTERPRET
- the internal name we will use everywhere.Template Method Pattern
The Template Method Pattern can be used when we want to build the skeleton of an algorithm that follows some default implementation but also allows specific instances of the algorithm to override certain steps with a custom implementation.
For example, in our API, we have an abstract class,
ActionHandler
, which defines a default method for each of the HTTP methods, and the specific instance - Interpret
- decides the methods for which it wants to provide a custom implementation.Additionally, the
Interpret
endpoint doesn’t need to implement the code that decides which requests go to which HTTP method, as all this logic is handled in the abstract template, and never needs to be repeated. We can add another endpoint easily (
Health
) which uses the default implementation for post
and creates its own specific implementation for get
.Tech Quality Analysis
Tech Quality: a measure of how tolerant a system is to changing the requirements without introducing defects.
I expect something to be of high tech quality (based on my definition), if it is easy to change the system to keep up with changing requirements.
Let’s say new requirements come in:
- add a GET request to
/interpret
which returns the same as the post request;
- add a new endpoint for a health check on the system at
/health
.
Changes Required to Add a GET Method to /interpret
This was a three-line change which took me less than 10 seconds to copy and paste over.
// Interpret.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/actions/handlers/Interpret.java package opt.api.actions.handlers; import java.util.Map; import opt.api.actions.ActionHandler; public class Interpret extends ActionHandler { protected String post( Map<String, String> head, Map<String, String> body ) { return "<SOME_RESPONSE_HERE>"; } + protected String get( Map<String, String> head, Map<String, String> body ) { + return "<SOME_RESPONSE_HERE>" + } }
Changes Required to Add a New /health Endpoint
Changes are required to 3 files to add a new endpoint, but the majority of it was very copy-and-pasteable! This took less than a minute to add a new endpoint.
+ // Health.java + package opt.api.actions.handlers; + import java.util.Map; + import opt.api.actions.ActionHandler; + public class Health extends ActionHandler { + protected String get( Map<String, String> head, Map<String, String> body ) { + return "The service is up and running"; + } + }
// Route.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/routing/Route.java package opt.api.routing; public enum Route { INTERPRET("interpret"); + HEALTH("health") private final String routeName; Route(String routeName) { this.routeName = routeName; } public String getRouteName() { return routeName; } // Static method to get the enum value from a string public static Route fromString(String text) { for (Route myEnum : Route.values()) { if (myEnum.routeName.equals(text)) { return myEnum; } } throw new IllegalArgumentException("No enum constant with text: " + text); } }
// Router.java: https://github.dev/martinwheatman/enguageMirror/blob/f3c712b78c62e0974bb29e9bc492edd61e994d29/opt/api/routing/Router.java package opt.api.routing; import java.util.Map; import opt.api.actions.handlers.Interpret; + import opt.api.actions.handlers.Health; import opt.api.utils.http.HttpException; public class Router { public String handle ( Map<String, String> head, Map<String, String> body ) throws HttpException { Route route = Route.fromString(head.get("route")); switch (route) { case INTERPRET: return new Interpret().handle(head, body); + case HEALTH: + return new Health().handle(head, body); default: throw new HttpException("404 Not Found", "This API route is not handled"); } } }
Summary
Design patterns are vital to make sure that your codebase scales and doesn’t deteriorate in quality with over time! Setting up simple design patterns in my API made it much simpler to keep developing at speed over time as more requirements come in for endpoints.
I hope this article also dispels some of the magic surrounding web frameworks - sometimes it seems like a framework can abstract away complexity to the extent that developers don’t understand what is going on under the hood, but behind the framework will sit a number of design patterns - similar to above - which help us write code at speed!