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