From 599a1c4a04056763989595e6061b2a0d09dcee77 Mon Sep 17 00:00:00 2001 From: Anshumali Prasad Date: Sun, 24 Dec 2023 10:13:24 -0800 Subject: [PATCH] Basic Router --- .../src/main/java/mdb/router/Main.java | 29 +++ .../src/main/java/mdb/router/Request.java | 31 +++ .../main/java/mdb/router/RequestHandler.java | 6 + .../src/main/java/mdb/router/Response.java | 21 ++ .../src/main/java/mdb/router/Router.java | 197 ++++++++++++++++++ .../test/main/java/mdb/router/RouterTest.java | 162 ++++++++++++++ 6 files changed, 446 insertions(+) create mode 100644 http-router/src/main/java/mdb/router/Main.java create mode 100644 http-router/src/main/java/mdb/router/Request.java create mode 100644 http-router/src/main/java/mdb/router/RequestHandler.java create mode 100644 http-router/src/main/java/mdb/router/Response.java create mode 100644 http-router/src/main/java/mdb/router/Router.java create mode 100644 http-router/test/main/java/mdb/router/RouterTest.java diff --git a/http-router/src/main/java/mdb/router/Main.java b/http-router/src/main/java/mdb/router/Main.java new file mode 100644 index 0000000..4a232c0 --- /dev/null +++ b/http-router/src/main/java/mdb/router/Main.java @@ -0,0 +1,29 @@ +package main.java.mdb.router; + +public class Main { + public static void main(String[] args) { + + Router router = new Router(); + router.addRoute("GET", "/echo", req -> new Response(200, req.getBody())); + router.addRoute("POST", "/submit", req -> new Response(201, "Created")); + router.addRoute("PUT", "/update", req -> new Response(200, "Updated")); + router.addRoute("DELETE", "/delete", req -> new Response(200, "Deleted")); + router.addRoute("GET", "/foo/{bar}", request -> new Response(200, "Received parameter: " + request.getPathParameter("bar"))); + + Response echoResponse = router.route("GET", "/echo", "Hello, MongoDB!"); + System.out.println("GET Response: " + echoResponse.getHTTPBody()); + + Response postResponse = router.route("POST", "/submit", "POST"); + System.out.println("POST Response: " + postResponse.getHTTPBody()); + + Response putResponse = router.route("PUT", "/update", "PUT"); + System.out.println("PUT Response: " + putResponse.getHTTPBody()); + + Response deleteResponse = router.route("DELETE", "/delete", ""); + System.out.println("DELETE Response: " + deleteResponse.getHTTPBody()); + + Response patternResponse = router.route("GET", "/foo/MongoDB", "GET Dynamic"); + System.out.println("GET Response for Dynamic route : " + patternResponse.getHTTPBody()); + + } +} diff --git a/http-router/src/main/java/mdb/router/Request.java b/http-router/src/main/java/mdb/router/Request.java new file mode 100644 index 0000000..2cd47ec --- /dev/null +++ b/http-router/src/main/java/mdb/router/Request.java @@ -0,0 +1,31 @@ +package main.java.mdb.router; + +import java.util.HashMap; +import java.util.Map; + +public class Request { + String path; + String method; + String body; + Map pathParameters; + + public Request(String method, String path, String body) { + this.path = path; + this.method = method; + this.body = body; + this.pathParameters = new HashMap<>(); + } + + public void addPathParameter(String name, String value) { + pathParameters.put(name, value); + } + + public String getPathParameter(String name) { + return pathParameters.get(name); + } + + public String getBody() { + return body; + } + +} diff --git a/http-router/src/main/java/mdb/router/RequestHandler.java b/http-router/src/main/java/mdb/router/RequestHandler.java new file mode 100644 index 0000000..c21c9f7 --- /dev/null +++ b/http-router/src/main/java/mdb/router/RequestHandler.java @@ -0,0 +1,6 @@ +package main.java.mdb.router; + +@FunctionalInterface +public interface RequestHandler { + Response handle(Request request); +} \ No newline at end of file diff --git a/http-router/src/main/java/mdb/router/Response.java b/http-router/src/main/java/mdb/router/Response.java new file mode 100644 index 0000000..b9fa448 --- /dev/null +++ b/http-router/src/main/java/mdb/router/Response.java @@ -0,0 +1,21 @@ +package main.java.mdb.router; + +public class Response { + + Integer responseCode; + String HTTPBody; + + public Integer getResponseCode() { + return responseCode; + } + + public String getHTTPBody() { + return HTTPBody; + } + + public Response(Integer responseCode, String HTTPBody) { + this.responseCode = responseCode; + this.HTTPBody = HTTPBody; + } + +} diff --git a/http-router/src/main/java/mdb/router/Router.java b/http-router/src/main/java/mdb/router/Router.java new file mode 100644 index 0000000..61516ea --- /dev/null +++ b/http-router/src/main/java/mdb/router/Router.java @@ -0,0 +1,197 @@ +package main.java.mdb.router; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Router { + private final Map routes = new HashMap<>(); + private final Map patternToHandlerKey = new HashMap<>(); + + private final Set validMethods = Set.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"); + + /** + * Adds a route to the router with a specified HTTP method, path, and handler. + * The route can be a static path or a dynamic segments enclosed in curly braces, e.g., "/users/{userId}". + * For dynamic paths the method creates a regular expression to match the path pattern and extract path parameters. + * + * @param method The HTTP method for the route (e.g., "GET", "POST"). + * @param path The path for the route, which can be static (e.g., "/users") or dynamic (e.g., "/users/{userId}"). + * @param handler The RequestHandler to be executed when the route is matched. This handler is responsible + * for processing the request and returning an appropriate Response. + */ + public void addRoute(String method, String path, RequestHandler handler) { + if (!isValidPath(path)) { + throw new IllegalArgumentException("Invalid path: " + path); + } + + RouteKey routeKey = new RouteKey(method.toUpperCase(), path); + routes.put(routeKey, handler); + + if (isDynamicPath(path)) { + String regex = path.replaceAll("\\{\\w+\\}", "([^/]+)"); + Pattern pattern = Pattern.compile("^" + regex + "$"); + patternToHandlerKey.put(pattern, routeKey); + } + } + + private boolean isDynamicPath(String path) { + return path.contains("{") && path.contains("}"); + } + + /** + * Validates the incoming path for addition + * @param path The path of the request + * @return A boolean indicating path is valid or not. + */ + private boolean isValidPath(String path) { + if (!path.startsWith("/")) { + return false; + } + + if (!path.matches("/[a-zA-Z0-9_/\\{\\}-]*")) { + return false; + } + + String[] segments = path.split("/"); + for (String segment : segments) { + if (segment.contains("{") || segment.contains("}")) { + if (!segment.matches("\\{\\w+\\}")) { + return false; + } + } + } + + if (path.contains("//") || path.contains("{/") || path.contains("/}") || path.contains("{}")) { + return false; + } + + return true; + } + + + /** + * Routes an HTTP request to the appropriate handler based on the method and path. + * It first checks for static routes and, if none are matched, checks for dynamic routes. + * If no route is found, returns a 404 Not Found response. + * + * @param method The HTTP method of the request. + * @param path The path of the request. + * @param body The body of the request. + * @return A Response object representing the result of the route handling. + */ + public Response route(String method, String path, String body) { + + Response validatorResponse = routeValidator(method, path); + if (validatorResponse != null) return validatorResponse; + + RouteKey staticRouteKey = new RouteKey(method.toUpperCase(), path); + + // Checking for a static route first, if there is a direct match + if (routes.containsKey(staticRouteKey)) { + RequestHandler handler = routes.get(staticRouteKey); + return handler.handle(new Request(method, path, body)); + } + + // If there is no static route then check for dynamic patterns + for (Map.Entry entry : patternToHandlerKey.entrySet()) { + Matcher matcher = entry.getKey().matcher(path); + if (matcher.matches()) { + RouteKey dynamicRouteKey = entry.getValue(); + if (dynamicRouteKey.getMethod().equals(method.toUpperCase())) { + RequestHandler handler = routes.get(dynamicRouteKey); + if (handler != null) { + Request request = new Request(method, path, body); + extractPathParameters(matcher, request, dynamicRouteKey.getPath()); + return handler.handle(request); + } + } + } + } + return new Response(404, "Not Found"); + } + + + /** + * Validates the incoming requests method and path. + * Checks if the method is valid and if the path is not empty. + * + * @param method The HTTP method to validate. + * @param path The path to validate. + * @return A Response object representing an error if validation fails; null if validation passes. + */ + private Response routeValidator(String method, String path){ + if (method == null || method.isEmpty()) { + return new Response(400, "HTTP Method is required"); + } + + if (!validMethods.contains(method.toUpperCase())) { + return new Response(405, "Invalid HTTP Method"); + } + + if (path == null || path.isEmpty()) { + return new Response(400, "Path is required"); + } + return null; + } + + /** + * Extract the path parameters from the matched pattern and adds them to the request. + * + * @param matcher The Matcher object that contains the pattern match results. + * @param request The Request object to which extracted path parameters will be added. + * @param path The path used to extract parameter names. + */ + private void extractPathParameters(Matcher matcher, Request request, String path) { + Pattern paramPattern = Pattern.compile("\\{\\w+\\}"); + Matcher paramMatcher = paramPattern.matcher(path); + + int index = 1; + while (paramMatcher.find()) { + String paramName = paramMatcher.group().substring(1, paramMatcher.group().length() - 1); // Remove '{' and '}' + String paramValue = matcher.group(index++); + request.addPathParameter(paramName, paramValue); + } + } + + /** + * This class is to create the composite key for the Route. + * The key is defined as a class to be easily expandable if we need to further optimize by + * adding additional parameters like methods as the part of the key. + */ + private static class RouteKey { + private final String method; + + private final String path; + + RouteKey(String method, String path) { + this.method = method; + this.path = path; + } + + public String getMethod() { + return method; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RouteKey routeKey = (RouteKey) o; + return method.equals(routeKey.method) && path.equals(routeKey.path); + } + + @Override + public int hashCode() { + return Objects.hash(method, path); + } + + public String getPath() { + return path; + } + } + +} diff --git a/http-router/test/main/java/mdb/router/RouterTest.java b/http-router/test/main/java/mdb/router/RouterTest.java new file mode 100644 index 0000000..c3e77fe --- /dev/null +++ b/http-router/test/main/java/mdb/router/RouterTest.java @@ -0,0 +1,162 @@ +package main.java.mdb.router; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RouterTest { + + private Router router; + + @BeforeEach + void setup() { + router = new Router(); + router.addRoute("GET", "/echo", request -> new Response(200, request.getBody())); + router.addRoute("GET", "/foo/{bar}", request -> new Response(200, request.getPathParameter("bar"))); + router.addRoute("POST", "/submit", request -> new Response(201, "Created")); + router.addRoute("GET", "/items/{itemId}", request -> new Response(200, request.getPathParameter("itemId"))); + router.addRoute("GET", "/users/{userId}/posts/{postId}/comments/{commentId}", request -> { + String response = "User: " + request.getPathParameter("userId") + + ", Post: " + request.getPathParameter("postId") + + ", Comment: " + request.getPathParameter("commentId"); + return new Response(200, response); + }); + } + + @Test + void testAddRouteWithInvalidPath() { + assertThrows(IllegalArgumentException.class, () -> { + router.addRoute("GET", "/echo/{", request -> new Response(200, "Invalid path")); + }, "Router should throw IllegalArgumentException for invalid path"); + } + @Test + void test_Echo_Route() { + Response response = router.route("GET", "/echo", "Hello, MongoDB!"); + assertEquals(200, response.getResponseCode()); + assertEquals("Hello, MongoDB!", response.getHTTPBody()); + } + + @Test + void test_PathParameter_Route() { + Response response = router.route("GET", "/foo/MongoDB", ""); + assertEquals(200, response.getResponseCode()); + assertEquals("MongoDB", response.getHTTPBody()); + } + + @Test + void test_Post_Route() { + Response response = router.route("POST", "/submit", "MongoDB"); + assertEquals(201, response.getResponseCode()); + assertEquals("Created", response.getHTTPBody()); + } + + @Test + void test_NotFound_Route() { + Response response = router.route("GET", "/notfound", ""); + assertEquals(404, response.getResponseCode()); + } + + @Test + void test_BadRequest_DueTo_EmptyMethod() { + Response response = router.route("", "/echo", "Hello, MongoDB!"); + assertEquals(400, response.getResponseCode()); + } + + @Test + void test_BadRequest_DueTo_NullMethod() { + Response response = router.route(null, "/echo", "Hello, MongoDB!"); + assertEquals(400, response.getResponseCode()); + } + + @Test + void test_BadRequest_DueTo_EmptyPath() { + Response response = router.route("GET", "", "Hello, MongoDB!"); + assertEquals(400, response.getResponseCode()); + } + + @Test + void test_BadRequest_DueTo_NullPath() { + Response response = router.route("GET", null, "Hello, MongoDB!"); + assertEquals(400, response.getResponseCode()); + } + + + @Test + void test_MultiplePath_Parameters() { + router.addRoute("GET", "/users/{userId}/posts/{postId}", + req -> new Response(200, "User: " + req.getPathParameter("userId") + ", Post: " + req.getPathParameter("postId"))); + Response response = router.route("GET", "/users/anshu/posts/1111", ""); + assertEquals(200, response.getResponseCode()); + assertEquals("User: anshu, Post: 1111", response.getHTTPBody()); + } + + @Test + void test_Overlapping_Routes() { + router.addRoute("GET", "/items/special", req -> new Response(200, "Special Items")); + Response responseForSpecial = router.route("GET", "/items/special", ""); + assertEquals(200, responseForSpecial.getResponseCode()); + assertEquals("Special Items", responseForSpecial.getHTTPBody()); + + Response responseForParam = router.route("GET", "/items/11", ""); + assertEquals(200, responseForParam.getResponseCode()); + assertEquals("11", responseForParam.getHTTPBody()); + } + + @Test + void test_CaseInsensitive_Method() { + Response response = router.route("get", "/echo", "Hello, MongoDB!"); + assertEquals(200, response.getResponseCode()); + assertEquals("Hello, MongoDB!", response.getHTTPBody()); + } + + @Test + void test_StaticPath_Priority() { + router.addRoute("GET", "/foo/static", req -> new Response(200, "Static Route")); + Response response = router.route("GET", "/foo/static", ""); + assertEquals(200, response.getResponseCode()); + assertEquals("Static Route", response.getHTTPBody()); + } + + @Test + void test_DifferentMethods_SamePath() { + + router.addRoute("POST", "/echo", req -> new Response(200, "POST Echo")); + + Response getResponse = router.route("GET", "/echo", "Hello, MongoDB!"); + assertEquals(200, getResponse.getResponseCode()); + assertEquals("Hello, MongoDB!", getResponse.getHTTPBody()); + + Response postResponse = router.route("POST", "/echo", ""); + assertEquals(200, postResponse.getResponseCode()); + assertEquals("POST Echo", postResponse.getHTTPBody()); + } + + @Test + void tes_InvalidHttp_Method() { + Response response = router.route("INVALID", "/echo", "Hello, MongoDB!"); + assertEquals(405, response.getResponseCode()); + } + + @Test + void test_NestedDynamic_PathParameters() { + Response response = router.route("GET", "/users/anshu/posts/111/comments/222", ""); + assertEquals(200, response.getResponseCode()); + assertEquals("User: anshu, Post: 111, Comment: 222", response.getHTTPBody()); + } + + @Test + void test_ResponseWith_NoBody() { + Response response = router.route("POST", "/submit", ""); + assertEquals(201, response.getResponseCode()); + assertEquals("Created", response.getHTTPBody()); + } + + @Test + void test_MultipleDynamicParameters() { + router.addRoute("GET", "/mix/{param1}/{param2}", req -> new Response(200, req.getPathParameter("param1") + "," + req.getPathParameter("param2"))); + Response response = router.route("GET", "/mix/anshu/prasad", ""); + assertEquals(200, response.getResponseCode()); + assertEquals("anshu,prasad", response.getHTTPBody()); + } + +}