From b17777636be7049b0db34aff9e5b9e04d86f0791 Mon Sep 17 00:00:00 2001 From: Joaquim Rocha Date: Sat, 12 Jul 2025 18:03:00 +0100 Subject: [PATCH] New route finding strategy Instead of using regexp to find the route associated to the request path. We are now using a tree, where each segment of the path is store on a leaf. --- README.md | 4 +- gradle.properties | 2 +- .../routing/InvalidRouteException.java | 7 + .../routing/PathPatternCompiler.java | 31 --- .../uiqui/embedhttp/routing/PathSegment.java | 162 +++++++++++++++ .../net/uiqui/embedhttp/routing/Route.java | 7 - .../uiqui/embedhttp/routing/RouteTree.java | 145 +++++++++++++ .../uiqui/embedhttp/routing/RouterImpl.java | 35 +--- .../embedhttp/routing/RoutingBuilder.java | 15 +- .../routing/PathPatternCompilerTest.java | 53 ----- .../embedhttp/routing/PathSegmentTest.java | 196 ++++++++++++++++++ .../uiqui/embedhttp/routing/RouteTest.java | 41 ---- .../embedhttp/routing/RouteTreeTest.java | 179 ++++++++++++++++ .../embedhttp/routing/RoutingBuilderTest.java | 14 +- 14 files changed, 709 insertions(+), 182 deletions(-) create mode 100644 src/main/java/net/uiqui/embedhttp/routing/InvalidRouteException.java delete mode 100644 src/main/java/net/uiqui/embedhttp/routing/PathPatternCompiler.java create mode 100644 src/main/java/net/uiqui/embedhttp/routing/PathSegment.java create mode 100644 src/main/java/net/uiqui/embedhttp/routing/RouteTree.java delete mode 100644 src/test/java/net/uiqui/embedhttp/routing/PathPatternCompilerTest.java create mode 100644 src/test/java/net/uiqui/embedhttp/routing/PathSegmentTest.java delete mode 100644 src/test/java/net/uiqui/embedhttp/routing/RouteTest.java create mode 100644 src/test/java/net/uiqui/embedhttp/routing/RouteTreeTest.java diff --git a/README.md b/README.md index b82ff75..29b31a7 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,13 @@ We can add EmbedHTTP to your project using Maven or Gradle. net.uiqui embedhttp - 0.5.4 + 0.5.5 ``` #### Gradle ```groovy -implementation 'net.uiqui:embedhttp:0.5.4' +implementation 'net.uiqui:embedhttp:0.5.5' ``` diff --git a/gradle.properties b/gradle.properties index 769e93c..4a837bc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ GROUP=net.uiqui POM_ARTIFACT_ID=embedhttp -VERSION_NAME=0.5.4 +VERSION_NAME=0.5.5 POM_NAME=EmbedHTTP POM_DESCRIPTION=A lightweight, dependency-free HTTP server for embedding in projects. diff --git a/src/main/java/net/uiqui/embedhttp/routing/InvalidRouteException.java b/src/main/java/net/uiqui/embedhttp/routing/InvalidRouteException.java new file mode 100644 index 0000000..c9fb15c --- /dev/null +++ b/src/main/java/net/uiqui/embedhttp/routing/InvalidRouteException.java @@ -0,0 +1,7 @@ +package net.uiqui.embedhttp.routing; + +public class InvalidRouteException extends RuntimeException { + public InvalidRouteException(String message) { + super(message); + } +} diff --git a/src/main/java/net/uiqui/embedhttp/routing/PathPatternCompiler.java b/src/main/java/net/uiqui/embedhttp/routing/PathPatternCompiler.java deleted file mode 100644 index 610bac9..0000000 --- a/src/main/java/net/uiqui/embedhttp/routing/PathPatternCompiler.java +++ /dev/null @@ -1,31 +0,0 @@ -package net.uiqui.embedhttp.routing; - -import java.util.regex.Pattern; - -public class PathPatternCompiler { - private static final Pattern paramPattern = Pattern.compile(":([a-zA-Z][a-zA-Z0-9]*)"); - - private PathPatternCompiler() { - // Prevent instantiation - } - - protected static String pathToRegex(String path) { - var matcher = paramPattern.matcher(path); - var regexBuffer = new StringBuilder(); - - while (matcher.find()) { - var paramName = matcher.group(1); - var paramReplacement = "(?<" + paramName + ">[^/]+)"; - matcher.appendReplacement(regexBuffer, paramReplacement); - } - - matcher.appendTail(regexBuffer); - - return regexBuffer.toString(); - } - - public static Pattern compile(String path) { - var regex = pathToRegex(path); - return Pattern.compile(regex); - } -} diff --git a/src/main/java/net/uiqui/embedhttp/routing/PathSegment.java b/src/main/java/net/uiqui/embedhttp/routing/PathSegment.java new file mode 100644 index 0000000..5556a80 --- /dev/null +++ b/src/main/java/net/uiqui/embedhttp/routing/PathSegment.java @@ -0,0 +1,162 @@ +package net.uiqui.embedhttp.routing; + + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public abstract sealed class PathSegment { + protected static final String MATCH_ALL = "*"; + + protected final PathSegment parent; + private final Map children = new HashMap<>(); + protected Route route; + + protected PathSegment(PathSegment parent, Route route) { + this.parent = parent; + this.route = route; + } + + public int getChildCount() { + return children.size(); + } + + public PathSegment findChild(String segment, boolean ignoreMachAll) { + var child = children.get(segment); + if (child != null) { + return child; + } + + if (ignoreMachAll) { + return null; + } + + return findParameterChild(); + } + + public PathSegment findParameterChild() { + return children.get(MATCH_ALL); + } + + public boolean hasRoute() { + return route != null; + } + + public Route getRoute() { + return route; + } + + public PathSegment registerParameterChild(String pathParameter, Route route) { + return registerChild(MATCH_ALL, new Parameter(this, pathParameter, route)); + } + + public PathSegment registerStaticChild(String pathSegment, Route route) { + return registerChild(pathSegment, new Static(this, pathSegment, route)); + } + + private PathSegment registerChild(String segmentKey, PathSegment child) { + if (children.containsKey(segmentKey)) { + throw new InvalidRouteException("Path segment '" + segmentKey + "' already exists at '" + this + "'."); + } + + children.put(segmentKey, child); + return child; + } + + public List getAllRoutes() { + var routes = new ArrayList(); + + if (hasRoute()) { + routes.add(getRoute()); + } + + for (PathSegment child : children.values()) { + routes.addAll(child.getAllRoutes()); + } + + return routes; + } + + public List getTreePaths() { + var paths = new ArrayList(); + var buffer = new StringBuilder(); + + buffer.append(this); + + if (hasRoute()) { + buffer.append("+"); + } + + paths.add(buffer.toString()); + + for (PathSegment child : children.values()) { + paths.addAll(child.getTreePaths()); + } + + return paths; + } + + static final class Root extends PathSegment { + public Root() { + super(null, null); + } + + @Override + public String toString() { + return "/"; + } + + public void setRoute(Route route) { + if (hasRoute()) { + throw new InvalidRouteException("Path segment '/' already has a handler."); + } + + this.route = route; + } + } + + static final class Parameter extends PathSegment { + private final String parameterName; + + private Parameter(PathSegment parent, String parameterName, Route handler) { + super(parent, handler); + this.parameterName = parameterName; + } + + public String getParameterName() { + return parameterName; + } + + @Override + public String toString() { + var parentPath = parent.toString(); + + if (!parentPath.endsWith("/")) { + parentPath += "/"; + } + + return parentPath + ":" + parameterName; + } + } + + static final class Static extends PathSegment { + private final String pathSegment; + + private Static(PathSegment parent, String pathSegment, Route handler) { + super(parent, handler); + this.pathSegment = pathSegment; + } + + @Override + public String toString() { + var parentPath = parent.toString(); + + if (!parentPath.endsWith("/")) { + parentPath += "/"; + } + + return parentPath + pathSegment; + } + } +} \ No newline at end of file diff --git a/src/main/java/net/uiqui/embedhttp/routing/Route.java b/src/main/java/net/uiqui/embedhttp/routing/Route.java index 0ee6798..88d40a0 100644 --- a/src/main/java/net/uiqui/embedhttp/routing/Route.java +++ b/src/main/java/net/uiqui/embedhttp/routing/Route.java @@ -4,18 +4,15 @@ import net.uiqui.embedhttp.api.HttpRequestHandler; import java.util.Objects; -import java.util.regex.Pattern; public class Route { private final HttpMethod method; private final String pathPattern; - private final Pattern pathRegexPattern; private final HttpRequestHandler handler; public Route(HttpMethod method, String pathPattern, HttpRequestHandler handler) { this.method = method; this.pathPattern = pathPattern; - this.pathRegexPattern = PathPatternCompiler.compile(pathPattern); this.handler = handler; } @@ -27,10 +24,6 @@ public String getPathPattern() { return pathPattern; } - public Pattern getPathRegexPattern() { - return pathRegexPattern; - } - public HttpRequestHandler getHandler() { return handler; } diff --git a/src/main/java/net/uiqui/embedhttp/routing/RouteTree.java b/src/main/java/net/uiqui/embedhttp/routing/RouteTree.java new file mode 100644 index 0000000..85aa572 --- /dev/null +++ b/src/main/java/net/uiqui/embedhttp/routing/RouteTree.java @@ -0,0 +1,145 @@ +package net.uiqui.embedhttp.routing; + +import java.util.HashMap; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public class RouteTree { + private static final Pattern PARAM_PATTERN = Pattern.compile("^:([a-zA-Z][a-zA-Z0-9]*)$"); + + private final PathSegment rootSegment = new PathSegment.Root(); + + public void addRoute(Route route) { + var pathSegments = slitPath(route.getPathPattern()); + + if (pathSegments.length == 0) { + throw new InvalidRouteException("Invalid path pattern: " + route.getPathPattern()); + } + + if (isRootPath(pathSegments)) { + // Special case for root path + ((PathSegment.Root) rootSegment).setRoute(route); + return; + } + + var currentSegment = rootSegment; + var lastSegmentIndex = pathSegments.length - 1; + + for (var i = 0; i <= lastSegmentIndex; i++) { + var segment = pathSegments[i]; + var isLastSegment = i == lastSegmentIndex; + currentSegment = handleSegment(segment, currentSegment, isLastSegment, route); + } + } + + private boolean isRootPath(String[] pathSegments) { + return pathSegments.length == 1 && pathSegments[0].equals("/"); + } + + private PathSegment handleSegment(String segment, PathSegment currentSegment, boolean isLastSegment, Route route) { + var segmentRoute = isLastSegment ? route : null; + + var parameterName = extractParameterName(segment); + if (parameterName != null) { + return handleParameterSegment(parameterName, currentSegment, segmentRoute); + } + + return handleStaticSegment(segment, currentSegment, segmentRoute); + } + + private PathSegment handleParameterSegment(String parameterName, PathSegment currentSegment, Route route) { + var parameterChild = currentSegment.findParameterChild(); + + if (parameterChild != null) { + return parameterChild; + } + + return currentSegment.registerParameterChild(parameterName, route); + } + + private PathSegment handleStaticSegment(String pathSegment, PathSegment currentSegment, Route route) { + var staticChild = currentSegment.findChild(pathSegment, true); + + if (staticChild != null) { + return staticChild; + } + + return currentSegment.registerStaticChild(pathSegment, route); + } + + public RouteMatch findRoute(String pathPattern) { + var pathSegments = slitPath(pathPattern); + if (pathSegments.length == 0) { + return null; + } + + if (isRootPath(pathSegments)) { + // Special case for root path + if (rootSegment.hasRoute()) { + return new RouteMatch(rootSegment.getRoute(), new HashMap<>()); + } else { + return null; // No route found for root path + } + } + + var pathParameters = new HashMap(); + var currentSegment = rootSegment; + + for (var segment : pathSegments) { + currentSegment = currentSegment.findChild(segment, false); + if (currentSegment == null) { + return null; // No matching segment found + } + + if (currentSegment instanceof PathSegment.Parameter parameter) { + pathParameters.put(parameter.getParameterName(), segment); + } + } + + if (!currentSegment.hasRoute()) { + return null; // No route found for the given path + } + + return new RouteMatch(currentSegment.getRoute(), pathParameters); + } + + public List getAllRoutes() { + return rootSegment.getAllRoutes(); + } + + public List getTreePaths() { + return rootSegment.getTreePaths() + .stream() + .sorted() + .toList(); + } + + protected String extractParameterName(String segment) { + var matcher = PARAM_PATTERN.matcher(segment); + + if (matcher.matches()) { + return matcher.group(1); + } + + return null; + } + + protected String[] slitPath(String pathPattern) { + if (pathPattern == null || pathPattern.isEmpty()) { + return new String[0]; + } + + if (pathPattern.equals("/")) { + return new String[]{"/"}; + } + + return Stream.of(pathPattern.split("/")) + .map(String::trim) + .filter(segment -> !segment.isEmpty()) + .toArray(String[]::new); + } + + public record RouteMatch(Route route, HashMap pathParameters) { + } +} diff --git a/src/main/java/net/uiqui/embedhttp/routing/RouterImpl.java b/src/main/java/net/uiqui/embedhttp/routing/RouterImpl.java index 0121a19..f1c63a6 100644 --- a/src/main/java/net/uiqui/embedhttp/routing/RouterImpl.java +++ b/src/main/java/net/uiqui/embedhttp/routing/RouterImpl.java @@ -3,46 +3,19 @@ import net.uiqui.embedhttp.api.impl.HttpRequestImpl; import net.uiqui.embedhttp.server.Request; -import java.util.HashMap; -import java.util.Map; -import java.util.regex.Matcher; - public class RouterImpl extends RoutingBuilder { public RouterImpl() { super(); } public HttpRequestImpl routeRequest(Request request) { - var validRoutes = getRoutesForMethod(request.getMethod()); + var routeTree = getRouteTreeForMethod(request.getMethod()); + var routeMatch = routeTree.findRoute(request.getPath()); - if (validRoutes.isEmpty()) { + if (routeMatch == null) { return null; } - for (var route : validRoutes) { - var matcher = route.getPathRegexPattern().matcher(request.getPath()); - - if (!matcher.matches()) { - continue; - } - - var pathParameters = extractPathParameters(matcher); - - return new HttpRequestImpl(request, route, pathParameters); - } - - return null; - } - - private static Map extractPathParameters(Matcher matcher) { - HashMap pathParameters = HashMap.newHashMap(matcher.groupCount()); - - for (var matchedGroup : matcher.namedGroups().entrySet()) { - var key = matchedGroup.getKey(); - var value = matcher.group(matchedGroup.getValue()); - pathParameters.put(key, value); - } - - return pathParameters; + return new HttpRequestImpl(request, routeMatch.route(), routeMatch.pathParameters()); } } diff --git a/src/main/java/net/uiqui/embedhttp/routing/RoutingBuilder.java b/src/main/java/net/uiqui/embedhttp/routing/RoutingBuilder.java index c251e78..d76b88a 100644 --- a/src/main/java/net/uiqui/embedhttp/routing/RoutingBuilder.java +++ b/src/main/java/net/uiqui/embedhttp/routing/RoutingBuilder.java @@ -4,21 +4,18 @@ import net.uiqui.embedhttp.api.HttpMethod; import net.uiqui.embedhttp.api.HttpRequestHandler; -import java.util.ArrayList; import java.util.EnumMap; -import java.util.List; import java.util.Map; -import static java.util.Collections.emptyList; - public abstract class RoutingBuilder implements Router { - private final Map> routingTable = new EnumMap<>(HttpMethod.class); + private static final RouteTree EMPTY_ROUTE_TREE = new RouteTree(); + private final Map routingTable = new EnumMap<>(HttpMethod.class); @Override public RoutingBuilder withRoute(HttpMethod method, String pathPattern, HttpRequestHandler handler) { var route = new Route(method, pathPattern, handler); - routingTable.computeIfAbsent(method, k -> new ArrayList<>()) - .add(route); + routingTable.computeIfAbsent(method, k -> new RouteTree()) + .addRoute(route); return this; } @@ -57,7 +54,7 @@ public RoutingBuilder patch(String pathPattern, HttpRequestHandler handler) { return withRoute(HttpMethod.PATCH, pathPattern, handler); } - public List getRoutesForMethod(HttpMethod method) { - return routingTable.getOrDefault(method, emptyList()); + public RouteTree getRouteTreeForMethod(HttpMethod method) { + return routingTable.getOrDefault(method, EMPTY_ROUTE_TREE); } } diff --git a/src/test/java/net/uiqui/embedhttp/routing/PathPatternCompilerTest.java b/src/test/java/net/uiqui/embedhttp/routing/PathPatternCompilerTest.java deleted file mode 100644 index ac319a1..0000000 --- a/src/test/java/net/uiqui/embedhttp/routing/PathPatternCompilerTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package net.uiqui.embedhttp.routing; - -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.Map; -import java.util.stream.Stream; - -import static java.util.Collections.emptyMap; -import static org.assertj.core.api.Assertions.assertThat; - -class PathPatternCompilerTest { - @ParameterizedTest - @MethodSource("pathRequests") - void testPathToRegex(String path, String regex) { - // when - var result = PathPatternCompiler.pathToRegex(path); - // then - assertThat(result).isEqualTo(regex); - } - - @ParameterizedTest - @MethodSource("pathRequests") - void testCompile(String path, String regex, String example, Map parms) { - // when - var compilePath = PathPatternCompiler.compile(path); - var matcher = compilePath.matcher(example); - // then - assertThat(matcher.matches()).isTrue(); - assertThat(matcher.groupCount()).isEqualTo(parms.size()); - - for (var entry : parms.entrySet()) { - assertThat(matcher.group(entry.getKey())).isEqualTo(entry.getValue()); - } - } - - private static Stream pathRequests() { - return Stream.of( - // path, regExp, example, parms - Arguments.of("/v1", "/v1", "/v1", emptyMap()), - Arguments.of("/v1/resource", "/v1/resource", "/v1/resource", emptyMap()), - Arguments.of("/v1/resource/:id", "/v1/resource/(?[^/]+)", "/v1/resource/123", Map.of("id", "123")), - Arguments.of("/v1/resource/:a1", "/v1/resource/(?[^/]+)", "/v1/resource/123", Map.of("a1", "123")), - Arguments.of("/v1/resource/:aZ", "/v1/resource/(?[^/]+)", "/v1/resource/123", Map.of("aZ", "123")), - Arguments.of("/v1/resource/:Z2", "/v1/resource/(?[^/]+)", "/v1/resource/123", Map.of("Z2", "123")), - Arguments.of("/v1/resource/:Za", "/v1/resource/(?[^/]+)", "/v1/resource/123", Map.of("Za", "123")), - Arguments.of("/v1/resource/:id/section", "/v1/resource/(?[^/]+)/section", "/v1/resource/123/section", Map.of("id", "123")), - Arguments.of("/v1/resource/:id/section/:name", "/v1/resource/(?[^/]+)/section/(?[^/]+)", - "/v1/resource/123/section/abc", Map.of("id", "123", "name", "abc")) - ); - } -} \ No newline at end of file diff --git a/src/test/java/net/uiqui/embedhttp/routing/PathSegmentTest.java b/src/test/java/net/uiqui/embedhttp/routing/PathSegmentTest.java new file mode 100644 index 0000000..097257e --- /dev/null +++ b/src/test/java/net/uiqui/embedhttp/routing/PathSegmentTest.java @@ -0,0 +1,196 @@ +package net.uiqui.embedhttp.routing; + +import net.uiqui.embedhttp.api.HttpMethod; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; + +class PathSegmentTest { + @Test + void testRootSegment() { + // when + var classUnderTest = new PathSegment.Root(); + // then + assertThat(classUnderTest.parent).isNull(); + assertThat(classUnderTest.hasRoute()).isFalse(); + assertThat(classUnderTest.getRoute()).isNull(); + assertThat(classUnderTest.toString()).hasToString("/"); + assertThat(classUnderTest.getChildCount()).isZero(); + assertThat(classUnderTest.getAllRoutes()).isEmpty(); + assertThat(classUnderTest.getTreePaths()).containsExactly("/"); + } + + @Test + void testStaticSegmentWithRoute() { + // given + var classUnderTest = new PathSegment.Root(); + var route = new Route(HttpMethod.GET, "/test", null); + // when + var result = classUnderTest.registerStaticChild("test", route); + // then + assertThat(classUnderTest.getChildCount()).isEqualTo(1); + assertThat(classUnderTest.findChild("test", true)).isSameAs(result); + assertThat(classUnderTest.getAllRoutes()).containsExactly(route); + assertThat(classUnderTest.getTreePaths()).containsExactly("/", "/test+"); + assertThat(result.parent).isEqualTo(classUnderTest); + assertThat(result.hasRoute()).isTrue(); + assertThat(result.getRoute()).isEqualTo(route); + assertThat(result.toString()).hasToString("/test"); + assertThat(result.getChildCount()).isZero(); + assertThat(result.getAllRoutes()).containsExactly(route); + assertThat(result.getTreePaths()).containsExactly("/test+"); + } + + @Test + void testStaticSegmentWithoutRoute() { + // given + var classUnderTest = new PathSegment.Root(); + // when + var result = classUnderTest.registerStaticChild("test", null); + // then + assertThat(classUnderTest.getChildCount()).isEqualTo(1); + assertThat(classUnderTest.findChild("test", true)).isSameAs(result); + assertThat(classUnderTest.getAllRoutes()).isEmpty(); + assertThat(classUnderTest.getTreePaths()).containsExactly("/", "/test"); + assertThat(result.parent).isEqualTo(classUnderTest); + assertThat(result.hasRoute()).isFalse(); + assertThat(result.getRoute()).isNull(); + assertThat(result.toString()).hasToString("/test"); + assertThat(result.getChildCount()).isZero(); + assertThat(result.getAllRoutes()).isEmpty(); + assertThat(result.getTreePaths()).containsExactly("/test"); + } + + @Test + void testParameterSegmentWithRoute() { + // given + var classUnderTest = new PathSegment.Root(); + var route = new Route(HttpMethod.GET, "/:name", null); + // when + var result = classUnderTest.registerParameterChild("name", route); + // then + assertThat(classUnderTest.getChildCount()).isEqualTo(1); + assertThat(classUnderTest.findChild("test", true)).isNull(); + assertThat(classUnderTest.findParameterChild()).isSameAs(result); + assertThat(classUnderTest.findChild("test", false)).isSameAs(result); + assertThat(classUnderTest.getAllRoutes()).containsExactly(route); + assertThat(classUnderTest.getTreePaths()).containsExactly("/", "/:name+"); + assertThat(result.parent).isEqualTo(classUnderTest); + assertThat(result.hasRoute()).isTrue(); + assertThat(result.getRoute()).isEqualTo(route); + assertThat(result.toString()).hasToString("/:name"); + assertThat(result.getChildCount()).isZero(); + assertThat(result.getAllRoutes()).containsExactly(route); + assertThat(result.getTreePaths()).containsExactly("/:name+"); + } + + @Test + void testParameterSegmentWithoutRoute() { + // given + var classUnderTest = new PathSegment.Root(); + // when + var result = classUnderTest.registerParameterChild("name", null); + // then + assertThat(classUnderTest.getChildCount()).isEqualTo(1); + assertThat(classUnderTest.findChild("test", true)).isNull(); + assertThat(classUnderTest.findParameterChild()).isSameAs(result); + assertThat(classUnderTest.findChild("test", false)).isSameAs(result); + assertThat(classUnderTest.getAllRoutes()).isEmpty(); + assertThat(classUnderTest.getTreePaths()).containsExactly("/", "/:name"); + assertThat(result.parent).isEqualTo(classUnderTest); + assertThat(result.hasRoute()).isFalse(); + assertThat(result.getRoute()).isNull(); + assertThat(result.toString()).hasToString("/:name"); + assertThat(result.getChildCount()).isZero(); + assertThat(result.getAllRoutes()).isEmpty(); + assertThat(result.getTreePaths()).containsExactly("/:name"); + } + + @Test + void testThreeStaticSegments() { + // given + var route = new Route(HttpMethod.GET, "/test1/test2/test3", null); + var classUnderTest = new PathSegment.Root(); + // when + var segment1 = classUnderTest.registerStaticChild("test1", null); + var segment2 = segment1.registerStaticChild("test2", null); + var segment3 = segment2.registerStaticChild("test3", route); + // then + assertThat(classUnderTest.getAllRoutes()).containsExactly(route); + assertThat(classUnderTest.getTreePaths()).containsExactly("/", "/test1", "/test1/test2", "/test1/test2/test3+"); + assertThat(segment3.toString()).hasToString("/test1/test2/test3"); + } + + @Test + void testThreeParameterSegments() { + // given + var route = new Route(HttpMethod.GET, "/:name1/:name2/:name3", null); + var classUnderTest = new PathSegment.Root(); + // when + var segment1 = classUnderTest.registerParameterChild("name1", null); + var segment2 = segment1.registerParameterChild("name2", null); + var segment3 = segment2.registerParameterChild("name3", route); + // then + assertThat(classUnderTest.getAllRoutes()).containsExactly(route); + assertThat(classUnderTest.getTreePaths()).containsExactly("/", "/:name1", "/:name1/:name2", "/:name1/:name2/:name3+"); + assertThat(segment3.toString()).hasToString("/:name1/:name2/:name3"); + } + + @Test + void testAddDuplicatedSegments() { + // given + var classUnderTest = new PathSegment.Root(); + // when + classUnderTest.registerStaticChild("test", null); + // then + var response = catchThrowable(() -> + classUnderTest.registerStaticChild("test", null) + ); + // when + assertThat(response).isInstanceOf(InvalidRouteException.class) + .hasMessage("Path segment 'test' already exists at '/'."); + } + + @Test + void testAddDuplicatedMatchAll() { + // given + var classUnderTest = new PathSegment.Root(); + // when + classUnderTest.registerParameterChild("clientId", null); + // then + var response = catchThrowable(() -> + classUnderTest.registerParameterChild("eventId", null) + ); + // when + assertThat(response).isInstanceOf(InvalidRouteException.class) + .hasMessage("Path segment '*' already exists at '/'."); + } + + @Test + void testSetRouteOnRoot() { + // given + var classUnderTest = new PathSegment.Root(); + var route = new Route(HttpMethod.GET, "/", null); + // when + classUnderTest.setRoute(route); + // then + assertThat(classUnderTest.hasRoute()).isTrue(); + assertThat(classUnderTest.getRoute()).isEqualTo(route); + assertThat(classUnderTest.getAllRoutes()).containsExactly(route); + } + + @Test + void testSetRouteOnRootWhenRouteAlreadyExists() { + // given + var classUnderTest = new PathSegment.Root(); + var route1 = new Route(HttpMethod.GET, "/", null); + classUnderTest.setRoute(route1); + // when + var route2 = new Route(HttpMethod.POST, "/", null); + var response = catchThrowable(() -> classUnderTest.setRoute(route2)); + // then + assertThat(response).isInstanceOf(InvalidRouteException.class) + .hasMessage("Path segment '/' already has a handler."); + } +} \ No newline at end of file diff --git a/src/test/java/net/uiqui/embedhttp/routing/RouteTest.java b/src/test/java/net/uiqui/embedhttp/routing/RouteTest.java deleted file mode 100644 index e898880..0000000 --- a/src/test/java/net/uiqui/embedhttp/routing/RouteTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package net.uiqui.embedhttp.routing; - -import net.uiqui.embedhttp.api.HttpMethod; -import net.uiqui.embedhttp.api.HttpRequestHandler; -import net.uiqui.embedhttp.api.HttpResponse; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.regex.Pattern; -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; - -class RouteTest { - @ParameterizedTest - @MethodSource("pathRequests") - void testBuilder(String path, Pattern regex) { - // given - HttpRequestHandler handler = req -> HttpResponse.noContent(); - // when - var result = new Route(HttpMethod.GET, path, handler); - // then - assertThat(result.getMethod()).isEqualTo(HttpMethod.GET); - assertThat(result.getPathPattern()).isEqualTo(path); - assertThat(result.getPathRegexPattern()).hasToString(regex.toString()); - assertThat(result.getHandler()).isEqualTo(handler); - } - - private static Stream pathRequests() { - return Stream.of( - // path, regExp - Arguments.of("/v1", Pattern.compile("/v1")), - Arguments.of("/v1/resource", Pattern.compile("/v1/resource")), - Arguments.of("/v1/resource/:id", Pattern.compile("/v1/resource/(?[^/]+)")), - Arguments.of("/v1/resource/:id/", Pattern.compile("/v1/resource/(?[^/]+)/")), - Arguments.of("/v1/resource/:id/section", Pattern.compile("/v1/resource/(?[^/]+)/section")), - Arguments.of("/v1/resource/:id/section/:name", Pattern.compile("/v1/resource/(?[^/]+)/section/(?[^/]+)")) - ); - } -} \ No newline at end of file diff --git a/src/test/java/net/uiqui/embedhttp/routing/RouteTreeTest.java b/src/test/java/net/uiqui/embedhttp/routing/RouteTreeTest.java new file mode 100644 index 0000000..bc30240 --- /dev/null +++ b/src/test/java/net/uiqui/embedhttp/routing/RouteTreeTest.java @@ -0,0 +1,179 @@ +package net.uiqui.embedhttp.routing; + +import net.uiqui.embedhttp.api.HttpMethod; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouteTreeTest { + @ParameterizedTest + @MethodSource("pathTests") + void testSlitPath(String path, List expectedSegments) { + // given + var classUnderTest = new RouteTree(); + // when + var result = classUnderTest.slitPath(path); + // then + assertThat(result).containsExactly(expectedSegments.toArray(new String[0])); + } + + @ParameterizedTest + @MethodSource("paramTests") + void testExtractParameterName(String segment, String expectedParam) { + // given + var classUnderTest = new RouteTree(); + // when + var result = classUnderTest.extractParameterName(segment); + // then + assertThat(result).isEqualTo(expectedParam); + } + + @Test + void testAddRootRoute() { + // given + var route = new Route(HttpMethod.GET, "/", null); + var classUnderTest = new RouteTree(); + // when + classUnderTest.addRoute(route); + // then + assertThat(classUnderTest.getAllRoutes()).containsExactly(route); + assertThat(classUnderTest.getTreePaths()).containsExactly("/+"); + } + + @Test + void testAddRoute() { + // given + var route = new Route(HttpMethod.GET, "/test/:param", null); + var classUnderTest = new RouteTree(); + // when + classUnderTest.addRoute(route); + // then + assertThat(classUnderTest.getAllRoutes()).containsExactly(route); + assertThat(classUnderTest.getTreePaths()).containsExactly("/", "/test", "/test/:param+"); + } + + @Test + void testAddRoutes() { + // given + var route0 = new Route(HttpMethod.GET, "/", null); + var route1 = new Route(HttpMethod.POST, "/v1/events", null); + var route2 = new Route(HttpMethod.GET, "/v1/events/:eventId", null); + var route3 = new Route(HttpMethod.POST, "/v2/events", null); + var route4 = new Route(HttpMethod.GET, "/v2/events/:eventId", null); + var route5 = new Route(HttpMethod.POST, "/v2/events/:eventId/tickets", null); + var route6 = new Route(HttpMethod.GET, "/v2/events/:eventId/tickets/:ticketId", null); + var classUnderTest = new RouteTree(); + // when + classUnderTest.addRoute(route0); + classUnderTest.addRoute(route1); + classUnderTest.addRoute(route2); + classUnderTest.addRoute(route3); + classUnderTest.addRoute(route4); + classUnderTest.addRoute(route5); + classUnderTest.addRoute(route6); + // then + assertThat(classUnderTest.getAllRoutes()).containsExactlyInAnyOrder( + route0, route1, route2, route3, route4, route5, route6 + ); + assertThat(classUnderTest.getTreePaths()).containsExactlyInAnyOrder( + "/+", "/v1", "/v1/events+", "/v1/events/:eventId+", + "/v2", "/v2/events+", "/v2/events/:eventId+", + "/v2/events/:eventId/tickets+", "/v2/events/:eventId/tickets/:ticketId+" + ); + } + + @ParameterizedTest + @MethodSource("findRouteTests") + void testFindRoute(String path, String expectedPathPattern) { + // given + var route0 = new Route(HttpMethod.GET, "/", null); + var route1 = new Route(HttpMethod.POST, "/v1/events", null); + var route2 = new Route(HttpMethod.GET, "/v1/events/:eventId", null); + var route3 = new Route(HttpMethod.POST, "/v2/events", null); + var route4 = new Route(HttpMethod.GET, "/v2/events/:eventId", null); + var route5 = new Route(HttpMethod.POST, "/v2/events/:eventId/tickets", null); + var route6 = new Route(HttpMethod.GET, "/v2/events/:eventId/tickets/:ticketId", null); + var classUnderTest = new RouteTree(); + // when + classUnderTest.addRoute(route0); + classUnderTest.addRoute(route1); + classUnderTest.addRoute(route2); + classUnderTest.addRoute(route3); + classUnderTest.addRoute(route4); + classUnderTest.addRoute(route5); + classUnderTest.addRoute(route6); + // when + var result = classUnderTest.findRoute(path); + // then + if (result == null) { + assertThat(expectedPathPattern).isNull(); + } else { + assertThat(result.route().getPathPattern()).isEqualTo(expectedPathPattern); + } + } + + @Test + void testWithoutRootRoute() { + // given + var route1 = new Route(HttpMethod.POST, "/v1/events", null); + var classUnderTest = new RouteTree(); + classUnderTest.addRoute(route1); + // when + var result = classUnderTest.findRoute("/"); + // then + assertThat(result).isNull(); + } + + private static Stream pathTests() { + return Stream.of( + // path, expectedSegments + Arguments.of("/", List.of("/")), + Arguments.of("segment", List.of("segment")), + Arguments.of("/segment", List.of("segment")), + Arguments.of("/segment/", List.of("segment")), + Arguments.of(":param", List.of(":param")), + Arguments.of("/:param", List.of(":param")), + Arguments.of("/:param/", List.of(":param")), + Arguments.of("/v1/segment", List.of("v1", "segment")), + Arguments.of("/v1/:param", List.of("v1", ":param")), + Arguments.of("/v1/segment/:param/", List.of("v1", "segment", ":param")) + ); + } + + private static Stream paramTests() { + return Stream.of( + // segment, expectedParam + Arguments.of(":param", "param"), + Arguments.of(":a1", "a1"), + Arguments.of(":aZ", "aZ"), + Arguments.of(":Z2", "Z2"), + Arguments.of(":Za", "Za"), + Arguments.of("staticSegment", null), + Arguments.of("anotherStaticSegment", null) + ); + } + + private static Stream findRouteTests() { + return Stream.of( + // path, expectedPathPattern + Arguments.of("/", "/"), + Arguments.of("/v1/events", "/v1/events"), + Arguments.of("/v1/events/", "/v1/events"), + Arguments.of("/v1/events/123", "/v1/events/:eventId"), + Arguments.of("/v1/events/123/", "/v1/events/:eventId"), + Arguments.of("/v2/events", "/v2/events"), + Arguments.of("/v2/events/456", "/v2/events/:eventId"), + Arguments.of("/v2/events/456/tickets", "/v2/events/:eventId/tickets"), + Arguments.of("/v2/events/456/tickets/789", "/v2/events/:eventId/tickets/:ticketId"), + Arguments.of("/v1/unknown", null), + Arguments.of("/v3", null), + Arguments.of("/v2/events/456/tickets/789/unknown", null) + ); + } +} \ No newline at end of file diff --git a/src/test/java/net/uiqui/embedhttp/routing/RoutingBuilderTest.java b/src/test/java/net/uiqui/embedhttp/routing/RoutingBuilderTest.java index 207fb81..d7e84dc 100644 --- a/src/test/java/net/uiqui/embedhttp/routing/RoutingBuilderTest.java +++ b/src/test/java/net/uiqui/embedhttp/routing/RoutingBuilderTest.java @@ -25,12 +25,12 @@ void testNewRouter() { // then assertThat(result).isInstanceOf(RoutingBuilder.class); var classUnderTest = (RoutingBuilder) result; - assertThat(classUnderTest.getRoutesForMethod(HttpMethod.GET)).containsExactly(new Route(HttpMethod.GET, "/get", handler)); - assertThat(classUnderTest.getRoutesForMethod(HttpMethod.POST)).containsExactly(new Route(HttpMethod.POST, "/post", handler)); - assertThat(classUnderTest.getRoutesForMethod(HttpMethod.PUT)).containsExactly(new Route(HttpMethod.PUT, "/put", handler)); - assertThat(classUnderTest.getRoutesForMethod(HttpMethod.DELETE)).containsExactly(new Route(HttpMethod.DELETE, "/delete", handler)); - assertThat(classUnderTest.getRoutesForMethod(HttpMethod.HEAD)).containsExactly(new Route(HttpMethod.HEAD, "/head", handler)); - assertThat(classUnderTest.getRoutesForMethod(HttpMethod.OPTIONS)).containsExactly(new Route(HttpMethod.OPTIONS, "/options", handler)); - assertThat(classUnderTest.getRoutesForMethod(HttpMethod.PATCH)).containsExactly(new Route(HttpMethod.PATCH, "/patch", handler)); + assertThat(classUnderTest.getRouteTreeForMethod(HttpMethod.GET).getAllRoutes()).containsExactly(new Route(HttpMethod.GET, "/get", handler)); + assertThat(classUnderTest.getRouteTreeForMethod(HttpMethod.POST).getAllRoutes()).containsExactly(new Route(HttpMethod.POST, "/post", handler)); + assertThat(classUnderTest.getRouteTreeForMethod(HttpMethod.PUT).getAllRoutes()).containsExactly(new Route(HttpMethod.PUT, "/put", handler)); + assertThat(classUnderTest.getRouteTreeForMethod(HttpMethod.DELETE).getAllRoutes()).containsExactly(new Route(HttpMethod.DELETE, "/delete", handler)); + assertThat(classUnderTest.getRouteTreeForMethod(HttpMethod.HEAD).getAllRoutes()).containsExactly(new Route(HttpMethod.HEAD, "/head", handler)); + assertThat(classUnderTest.getRouteTreeForMethod(HttpMethod.OPTIONS).getAllRoutes()).containsExactly(new Route(HttpMethod.OPTIONS, "/options", handler)); + assertThat(classUnderTest.getRouteTreeForMethod(HttpMethod.PATCH).getAllRoutes()).containsExactly(new Route(HttpMethod.PATCH, "/patch", handler)); } } \ No newline at end of file