From d4e0e12a52af944744f4aaf881e819e19ad156a4 Mon Sep 17 00:00:00 2001 From: Alex Danilenko Date: Mon, 22 Apr 2024 22:24:20 +0200 Subject: [PATCH] Trie implementation for Route matching --- build.gradle | 7 +- .../SpottyRouteDuplicationException.java | 24 ++ .../java/spotty/common/utils/Memoized.java | 6 +- .../java/spotty/common/utils/RouterUtils.java | 7 +- .../files/detector/FileTypeDetector.java | 7 +- .../java/spotty/server/router/Routable.java | 215 ++++++-------- .../java/spotty/server/router/RouteNode.java | 88 ++++++ .../spotty/server/router/SpottyRouter.java | 2 +- .../java/spotty/server/router/TrieRoutes.java | 229 +++++++++++++++ .../common/utils/RouterUtilsTest.groovy | 4 + .../spotty/server/router/RoutableTest.groovy | 18 +- .../server/router/TrieRoutesTest.groovy | 275 ++++++++++++++++++ 12 files changed, 726 insertions(+), 156 deletions(-) create mode 100644 core/src/main/java/spotty/common/exception/SpottyRouteDuplicationException.java create mode 100644 core/src/main/java/spotty/server/router/RouteNode.java create mode 100644 core/src/main/java/spotty/server/router/TrieRoutes.java create mode 100644 core/src/test/groovy/spotty/server/router/TrieRoutesTest.groovy diff --git a/build.gradle b/build.gradle index f3c87fc..f516cf3 100644 --- a/build.gradle +++ b/build.gradle @@ -13,12 +13,7 @@ subprojects { apply plugin: 'jacoco' apply plugin: 'org.unbroken-dome.test-sets' - ext { - jvmVersion = JavaVersion.VERSION_1_8 - } - - sourceCompatibility = jvmVersion - targetCompatibility = jvmVersion + java.toolchain.languageVersion = JavaLanguageVersion.of(8) compileJava.options.encoding = 'UTF-8' diff --git a/core/src/main/java/spotty/common/exception/SpottyRouteDuplicationException.java b/core/src/main/java/spotty/common/exception/SpottyRouteDuplicationException.java new file mode 100644 index 0000000..a5294ef --- /dev/null +++ b/core/src/main/java/spotty/common/exception/SpottyRouteDuplicationException.java @@ -0,0 +1,24 @@ +/* + * Copyright 2022 - Alex Danilenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package spotty.common.exception; + +public class SpottyRouteDuplicationException extends SpottyException { + + public SpottyRouteDuplicationException(String message, Object... args) { + super(message, args); + } + +} diff --git a/core/src/main/java/spotty/common/utils/Memoized.java b/core/src/main/java/spotty/common/utils/Memoized.java index a82e1c9..5c83bf9 100644 --- a/core/src/main/java/spotty/common/utils/Memoized.java +++ b/core/src/main/java/spotty/common/utils/Memoized.java @@ -26,7 +26,7 @@ private Memoized() { public static Supplier lazy(Supplier supplier) { return new Supplier() { - private T memoized; + private volatile T memoized; @Override public T get() { @@ -41,8 +41,8 @@ public T get() { public static IntSupplier lazy(IntSupplier supplier) { return new IntSupplier() { - private boolean isMemoized = false; - private int value; + private volatile boolean isMemoized = false; + private volatile int value; @Override public int getAsInt() { diff --git a/core/src/main/java/spotty/common/utils/RouterUtils.java b/core/src/main/java/spotty/common/utils/RouterUtils.java index 2d69974..f581318 100644 --- a/core/src/main/java/spotty/common/utils/RouterUtils.java +++ b/core/src/main/java/spotty/common/utils/RouterUtils.java @@ -45,7 +45,12 @@ public static Result compileMatcher(String pathTemplate) { } public static String normalizePath(String path) { - return path.replaceAll(REGEX, "*$2"); + if (!path.startsWith("/")) { + path = "/" + path; + } + + return path.replaceAll(REGEX, "*$2") + .replaceAll("\\*+", "*"); } public static class Result { diff --git a/core/src/main/java/spotty/server/files/detector/FileTypeDetector.java b/core/src/main/java/spotty/server/files/detector/FileTypeDetector.java index ed99b9f..e02dc7b 100644 --- a/core/src/main/java/spotty/server/files/detector/FileTypeDetector.java +++ b/core/src/main/java/spotty/server/files/detector/FileTypeDetector.java @@ -18,14 +18,17 @@ import org.apache.tika.Tika; import java.net.URL; +import java.util.function.Supplier; + +import static spotty.common.utils.Memoized.lazy; public final class FileTypeDetector implements TypeDetector { - private final Tika tika = new Tika(); + private static final Supplier tika = lazy(() -> new Tika()); @Override public String detect(URL path) throws Exception { - return tika.detect(path); + return tika.get().detect(path); } } diff --git a/core/src/main/java/spotty/server/router/Routable.java b/core/src/main/java/spotty/server/router/Routable.java index db2fbf7..1f6c82d 100644 --- a/core/src/main/java/spotty/server/router/Routable.java +++ b/core/src/main/java/spotty/server/router/Routable.java @@ -16,36 +16,32 @@ package spotty.server.router; import com.google.common.annotations.VisibleForTesting; -import spotty.common.exception.SpottyException; import spotty.common.exception.SpottyHttpException; import spotty.common.exception.SpottyNotFoundException; +import spotty.common.exception.SpottyRouteDuplicationException; import spotty.common.http.HttpMethod; import spotty.common.router.route.Route; import spotty.common.router.route.RouteEntry; -import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.regex.Pattern; +import java.util.function.Function; -import static java.util.Collections.emptyMap; -import static java.util.stream.Collectors.toList; import static spotty.common.utils.RouterUtils.normalizePath; import static spotty.common.validation.Validation.notBlank; import static spotty.common.validation.Validation.notNull; import static spotty.server.router.SpottyRouter.DEFAULT_ACCEPT_TYPE; /** - * Main Routing core class + * Main routing core class responsible for managing routes and handling requests. + * This class utilizes a Trie-based router for efficient route matching. */ @VisibleForTesting final class Routable { - // store handlers by link, so removing from it is also affected this list - final SortedList sortedList = new SortedList(); + private static final Function> CREATE_NEW_MAP = __ -> new HashMap<>(); + + // store handlers by link, so removing from it is also affected trie + final TrieRoutes trieRoutes = new TrieRoutes(); /* routing map @@ -57,12 +53,27 @@ final class Routable { } } */ - final Map>> routes = new HashMap<>(); + final Map routes = new HashMap<>(); + /** + * Adds a route to the routing table. + * + * @param routePath The path of the route + * @param method The HTTP method of the route + * @param route The route to be added + */ synchronized void addRoute(String routePath, HttpMethod method, Route route) { addRoute(routePath, method, DEFAULT_ACCEPT_TYPE, route); } + /** + * Adds a route to the routing table. + * + * @param routePath The path of the route + * @param method The HTTP method of the route + * @param acceptType The accept type of the route + * @param route The route to be added + */ synchronized void addRoute(String routePath, HttpMethod method, String acceptType, Route route) { notNull("method", method); notBlank("acceptType", acceptType); @@ -71,59 +82,75 @@ synchronized void addRoute(String routePath, HttpMethod method, String acceptTyp final String path = notBlank("path is empty", routePath).trim(); final RouteEntry routeEntry = RouteEntryFactory.create(path, method, acceptType, route); - final Map> routeHandlers = routes.computeIfAbsent( - routeEntry.pathNormalized(), - pathNormalized -> { - final Map> handlers = new HashMap<>(); - sortedList.add(new Value(pathNormalized, handlers, routeEntry.matcher())); - - return handlers; - } - ); - - final Map routesWithAcceptType = routeHandlers.computeIfAbsent(method, __ -> new HashMap<>()); + final RouteNode routeNode = routes.computeIfAbsent(routeEntry.pathNormalized(), pathNormalized -> trieRoutes.add(pathNormalized, new HashMap<>())); + final Map routesWithAcceptType = routeNode.handlers.computeIfAbsent(method, createEmptyMap()); if (routesWithAcceptType.containsKey(acceptType)) { - throw new SpottyException("%s(%s) %s is exists already", method, acceptType, path); + throw new SpottyRouteDuplicationException("%s(%s) %s is exists already", method, acceptType, path); } routesWithAcceptType.put(acceptType, routeEntry); } + /** + * Clears all registered routes from the routing table. + */ synchronized void clearRoutes() { routes.clear(); - sortedList.clear(); + trieRoutes.clear(); } + /** + * Removes a route from the routing table by its path. + * + * @param routePath The path of the route to be removed + * @return True if the route was successfully removed, false otherwise + */ synchronized boolean removeRoute(String routePath) { notBlank("routePath", routePath); final String normalizedPath = normalizePath(routePath); - return routes.remove(normalizedPath) != null && sortedList.removeByPath(normalizedPath); + return routes.remove(normalizedPath) != null && trieRoutes.removeExactly(normalizedPath); } + /** + * Removes a route from the routing table by its path and HTTP method. + * + * @param routePath The path of the route to be removed + * @param method The HTTP method of the route to be removed + * @return True if the route was successfully removed, false otherwise + */ synchronized boolean removeRoute(String routePath, HttpMethod method) { notBlank("routePath", routePath); notNull("method", method); - final Map> route = routes.get(normalizePath(routePath)); - if (route == null) { + final RouteNode node = routes.get(normalizePath(routePath)); + if (node == null) { return false; } - return route.remove(method) != null; + // remove method from handlers, this is not require update trieRoutes as handlers will be removed by link + return node.handlers.remove(method) != null; } + /** + * Removes a route from the routing table by its path, HTTP method, and accept type. + * + * @param routePath The path of the route to be removed + * @param method The HTTP method of the route to be removed + * @param acceptType The accept type of the route to be removed + * @return True if the route was successfully removed, false otherwise + */ synchronized boolean removeRoute(String routePath, HttpMethod method, String acceptType) { notBlank("routePath", routePath); notNull("method", method); notBlank("acceptType", acceptType); - final Map> route = routes.get(normalizePath(routePath)); - if (route == null) { + final RouteNode node = routes.get(normalizePath(routePath)); + if (node == null) { return false; } - final Map acceptTypeRoutes = route.get(method); + final Map acceptTypeRoutes = node.handlers.get(method); if (acceptTypeRoutes == null) { return false; } @@ -131,17 +158,38 @@ synchronized boolean removeRoute(String routePath, HttpMethod method, String acc return acceptTypeRoutes.remove(acceptType) != null; } + /** + * Retrieves the route entry for a given raw path and HTTP method. + * + * @param rawPath The raw path of the request + * @param method The HTTP method of the request + * @return The route entry matching the given path and method + * @throws SpottyHttpException if the route is not found + */ RouteEntry getRoute(String rawPath, HttpMethod method) throws SpottyHttpException { return getRoute(rawPath, method, null); } + /** + * Retrieves the route entry for a given raw path, HTTP method, and accept type. + * + * @param rawPath The raw path of the request + * @param method The HTTP method of the request + * @param acceptType The accept type of the request + * @return The route entry matching the given path, method, and accept type + * @throws SpottyHttpException if the route is not found + */ RouteEntry getRoute(String rawPath, HttpMethod method, String acceptType) throws SpottyHttpException { - Map> routes = this.routes.get(rawPath); - if (routes == null) { - routes = findMatch(rawPath); + RouteNode routeNode = this.routes.get(rawPath); + if (routeNode == null) { + routeNode = trieRoutes.findRouteNode(rawPath); + } + + if (routeNode == null) { + throw new SpottyNotFoundException("route not found for %s", rawPath); } - final Map entry = routes.get(method); + final Map entry = routeNode.handlers.get(method); if (entry == null) { throw new SpottyNotFoundException("route not found for %s %s", method, rawPath); } @@ -159,95 +207,8 @@ RouteEntry getRoute(String rawPath, HttpMethod method, String acceptType) throws return routeEntry; } - private Map> findMatch(String rawPath) { - for (int i = 0; i < sortedList.size(); i++) { - final Value value = sortedList.get(i); - if (value.matches(rawPath)) { - return value.handlers; - } - } - - return emptyMap(); - } - - /** - *

This class sorts the path list of routes by pathNormalized from longest to shortest (of path length). - * Upon receiving the request, the server will start searching in sequence - * from the longest path to the shortest path.

- * - *

Why sort from longest to shortest routes?

- * - * Take for example two routes: - *
    - *
  • /user/*
  • - *
  • /user/:id/password/reset
  • - *
- * - *

If the server searches from shortest to longest instead, - * in this example it would take the request and match for any path (user/*), - * instead of the correct path (user/:id/password/reset).

- */ - static class SortedList { - private static final Comparator FROM_LONGEST_TO_SHORTEST_COMPARATOR = - (a, b) -> b.pathNormalized.length() - a.pathNormalized.length(); - - private final ArrayList values = new ArrayList<>(); - - private void add(Value value) { - values.add(value); - values.sort(FROM_LONGEST_TO_SHORTEST_COMPARATOR); - } - - private Value get(int index) { - return values.get(index); - } - - private boolean removeByPath(String pathNormalized) { - for (int i = 0; i < values.size(); i++) { - if (values.get(i).pathNormalized.equals(pathNormalized)) { - return values.remove(i) != null; - } - } - - return false; - } - - private void clear() { - values.clear(); - } - - private int size() { - return values.size(); - } - - void forEachRouteIf(Predicate predicate, Consumer consumer) { - values.stream() - .map(value -> value.handlers) - .flatMap(map -> map.values().stream()) - .flatMap(map -> map.values().stream()) - .filter(predicate) - .forEach(consumer); - } - - @VisibleForTesting - List toNormalizedPaths() { - return values.stream().map(v -> v.pathNormalized).collect(toList()); - } - } - - static class Value { - final String pathNormalized; - final Map> handlers; - final Pattern matcher; - - private Value(String pathNormalized, Map> handlers, Pattern matcher) { - this.pathNormalized = pathNormalized; - this.handlers = handlers; - this.matcher = matcher; - } - - boolean matches(String rawPath) { - return matcher.matcher(rawPath).matches(); - } + @SuppressWarnings("unchecked") + private static Function> createEmptyMap() { + return (Function>) (Function) CREATE_NEW_MAP; } } diff --git a/core/src/main/java/spotty/server/router/RouteNode.java b/core/src/main/java/spotty/server/router/RouteNode.java new file mode 100644 index 0000000..94dcc93 --- /dev/null +++ b/core/src/main/java/spotty/server/router/RouteNode.java @@ -0,0 +1,88 @@ +/* + * Copyright 2022 - Alex Danilenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package spotty.server.router; + +import spotty.common.http.HttpMethod; +import spotty.common.router.route.RouteEntry; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static java.util.Collections.emptyMap; + +/** + * Represents a node in the Trie-based router. + * Each node stores information about a specific character in a path. + */ +class RouteNode { + // The character key of the node + final char key; + + // The normalized path associated with the node + String pathNormalized; + + // Map of route handlers associated with HTTP methods and accept types + Map> handlers = emptyMap(); + + // Flag indicating whether the node represents a route + boolean isRoute; + + // Parent node reference + RouteNode parent; + + // Map of child nodes indexed by character keys + Map children = emptyMap(); + + RouteNode(char key) { + this.key = key; + } + + /** + * Adds a child node to the current node. + * + * @param node The child node to be added + */ + void addChild(RouteNode node) { + if (children.equals(emptyMap())) { + children = new HashMap<>(); + } + + node.parent = this; + children.put(node.key, node); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RouteNode node = (RouteNode) o; + return key == node.key && + isRoute == node.isRoute && + Objects.equals(pathNormalized, node.pathNormalized) && + Objects.equals(handlers, node.handlers); + } + + @Override + public int hashCode() { + return Objects.hash(key, isRoute, pathNormalized, handlers); + } + + @Override + public String toString() { + return "[key=" + key + ",path=" + pathNormalized + ",isRoute=" + isRoute + "]"; + } +} diff --git a/core/src/main/java/spotty/server/router/SpottyRouter.java b/core/src/main/java/spotty/server/router/SpottyRouter.java index 33f98c9..3270e29 100644 --- a/core/src/main/java/spotty/server/router/SpottyRouter.java +++ b/core/src/main/java/spotty/server/router/SpottyRouter.java @@ -490,7 +490,7 @@ private void registerAllMatchedFilters() { } private void addFilterToRoute(Pattern matcher, HttpMethod method, String acceptType, Filter filter, BiConsumer adder) { - routable.sortedList.forEachRouteIf( + routable.trieRoutes.forEachRouteIf( route -> { if (!matcher.matcher(route.pathNormalized()).matches()) { return false; diff --git a/core/src/main/java/spotty/server/router/TrieRoutes.java b/core/src/main/java/spotty/server/router/TrieRoutes.java new file mode 100644 index 0000000..266ecb1 --- /dev/null +++ b/core/src/main/java/spotty/server/router/TrieRoutes.java @@ -0,0 +1,229 @@ +/* + * Copyright 2022 - Alex Danilenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package spotty.server.router; + +import com.google.common.annotations.VisibleForTesting; +import spotty.common.http.HttpMethod; +import spotty.common.router.route.RouteEntry; + +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static java.util.Collections.emptyMap; + +/** + *

Represents a Trie Router implementation. + * Each registered path is split into a series of nodes, with each node representing a character in the path. + * The Trie supports wildcard shortcuts '*', which represent any path segment between slashes (/ * /).

+ */ +class TrieRoutes { + private static final Character PLACEHOLDER = '*'; + + final RouteNode root = new RouteNode('\0'); + + /** + * Adds a route to the Trie Router. + * + * @param pathNormalized The normalized path to add to the router. + * @param routeHandlers The handlers associated with the route. + * @return The node representing the added route. + */ + RouteNode add(String pathNormalized, Map> routeHandlers) { + RouteNode current = root; + for (int i = 0; i < pathNormalized.length(); i++) { + final char ch = pathNormalized.charAt(i); + RouteNode node = current.children.get(ch); + if (node == null) { + node = new RouteNode(ch); + current.addChild(node); + } + + current = node; + } + + current.isRoute = true; + current.pathNormalized = pathNormalized; + current.handlers = routeHandlers; + + return current; + } + + /** + * Finds the route node corresponding to the given raw path. + * + * @param rawPath The raw path for which to find the route node. + * @return The route node corresponding to the raw path, or null if not found. + */ + RouteNode findRouteNode(String rawPath) { + final RouteNode node = findNode(rawPath, 0, root); + if (node != null && node.isRoute) { + return node; + } + + return null; + } + + /** + * Finds the node in the Trie Router corresponding to the given path. + * This method recursively traverses the Trie structure to locate the node. + * It handles wildcard shortcuts '*' in the path by navigating to the appropriate node representing the wildcard. + * + * @param path The path for which to find the corresponding node. + * @param index The index representing the current position in the path. + * @param current The current node being examined during traversal. + * @return The node in the Trie Router corresponding to the given path, + * or null if no such node is found. + * + *

The findNode method recursively searches the Trie Router structure to locate the node + * representing the given path. It starts from the root node and traverses the tree based on + * the characters in the path. If the current character is not found in the children of the current node, + * the method checks if there is a wildcard node ('*') representing any path segment between slashes. + * If such a wildcard node exists, the method navigates to it and continues the search.

+ * + *

For example, consider the following Trie structure: + *

+     *                root
+     *                 |
+     *                "/"
+     *              /  |  \
+     *             u   *   a
+     *            / \      |
+     *           s   *     t
+     *          / \        |
+     *         e   r       r
+     *        /
+     *       r
+     *      /
+     *    "/"
+     *    /
+     *   *
+     * 
+ *

+ *

If we call findNode("/user/test", 0, root), the method will traverse the Trie as follows:

+ *
    + *
  1. Start from the root node.
  2. + *
  3. Check if there is a child node 'u', proceed to it.
  4. + *
  5. Check if there is a child node 's', proceed to it.
  6. + *
  7. Check if there is a child node 'e', proceed to it.
  8. + *
  9. Check if there is a child node 'r', proceed to it.
  10. + *
  11. Reach the end of the path ("/user/test").
  12. + *
  13. Return the node corresponding to the path.
  14. + *
+ *

If the path contains wildcard segments (e.g., "/user/*"), the findNode method will + * handle them appropriately by navigating to the corresponding wildcard node and continuing the search.

+ * + *

The time complexity of this method is O(n), where n is the length of the path. + * The space complexity is also O(n) due to the recursive nature of the algorithm.

+ */ + private RouteNode findNode(String path, int index, RouteNode current) { + if (current == null) { + return null; + } + + if (index < 0 || index >= path.length()) { + return current; + } + + final char ch = path.charAt(index); + RouteNode node = findNode(path, index + 1, current.children.get(ch)); + if (node == null) { + final RouteNode nodePlaceholder = current.children.get(PLACEHOLDER); + if (nodePlaceholder != null) { + if (nodePlaceholder.key == '*' && nodePlaceholder.isRoute && nodePlaceholder.children.isEmpty()) { + return nodePlaceholder; + } + + final RouteNode child = findNode(path, path.indexOf('/', index), nodePlaceholder); + if (child != null) { + return child; + } + } + } + + return node; + } + + /** + * Removes the route exactly matching the specified normalized path. + * + * @param normalizedPath The normalized path of the route to remove. + * @return True if the route was successfully removed, false otherwise. + */ + boolean removeExactly(String normalizedPath) { + RouteNode node = findRouteNode(normalizedPath); + if (node == null) { + return false; + } + + while (node != null && node != root) { + node.pathNormalized = null; + node.handlers = emptyMap(); + node.isRoute = false; + + if (node.children.isEmpty()) { + node.parent.children.remove(node.key); + } + + node = node.parent; + } + + return true; + } + + /** + * Clears all routes from the Trie Router. + */ + void clear() { + root.children.clear(); + } + + /** + * Executes the provided consumer for each route entry in the Trie Router + * that satisfies the specified predicate. + * + * @param predicate The predicate to filter route entries. + * @param consumer The consumer to execute for each matching route entry. + */ + void forEachRouteIf(Predicate predicate, Consumer consumer) { + final Deque queue = new LinkedList<>(root.children.values()); + while (!queue.isEmpty()) { + final RouteNode current = queue.remove(); + queue.addAll(current.children.values()); + + if (current.isRoute) { + current.handlers + .values() + .stream() + .flatMap(routes -> routes.values().stream()) + .filter(predicate) + .forEach(consumer); + } + } + } + + @VisibleForTesting + List toNormalizedPaths() { + final List result = new ArrayList<>(); + forEachRouteIf(__ -> true, route -> result.add(route.pathNormalized())); + + return result; + } +} diff --git a/core/src/test/groovy/spotty/common/utils/RouterUtilsTest.groovy b/core/src/test/groovy/spotty/common/utils/RouterUtilsTest.groovy index e907199..53a59f8 100644 --- a/core/src/test/groovy/spotty/common/utils/RouterUtilsTest.groovy +++ b/core/src/test/groovy/spotty/common/utils/RouterUtilsTest.groovy @@ -23,7 +23,11 @@ class RouterUtilsTest extends Specification { "/api/*" | "/api/*" "/api/*/product/*/category/:category" | "/api/*/product/*/category/*" "/:name/user/:id/*/delete" | "/*/user/*/*/delete" + "*" | "/*" "/*" | "/*" + "/**" | "/*" + "/******/***/*" | "/*/*/*" + "api/:name/***/**/*" | "/api/*/*/*/*" } def "should compile correctly"() { diff --git a/core/src/test/groovy/spotty/server/router/RoutableTest.groovy b/core/src/test/groovy/spotty/server/router/RoutableTest.groovy index 5a234af..4b32440 100644 --- a/core/src/test/groovy/spotty/server/router/RoutableTest.groovy +++ b/core/src/test/groovy/spotty/server/router/RoutableTest.groovy @@ -1,8 +1,8 @@ package spotty.server.router import spock.lang.Specification -import spotty.common.exception.SpottyException import spotty.common.exception.SpottyHttpException +import spotty.common.exception.SpottyRouteDuplicationException import spotty.common.router.route.Route import static org.apache.http.entity.ContentType.APPLICATION_JSON @@ -134,27 +134,13 @@ class RoutableTest extends Specification { route3 == route3Found.route() } - def "should sort templates from longest to shortest"() { - given: - var routes = ["/hello/*/world", "/hello-world", "/hello/*/*", "/hello"] - - when: - routable.addRoute("/hello/:user/:name", GET, {}) - routable.addRoute("/hello", POST, {}) - routable.addRoute("/hello-world", POST, {}) - routable.addRoute("/hello/*/world", POST, {}) - - then: - routes == routable.sortedList.toNormalizedPaths() - } - def "should throw an error when path is duplicated"() { when: routable.addRoute(path1, GET, {}) routable.addRoute(path2, GET, {}) then: - thrown SpottyException + thrown SpottyRouteDuplicationException where: path1 | path2 diff --git a/core/src/test/groovy/spotty/server/router/TrieRoutesTest.groovy b/core/src/test/groovy/spotty/server/router/TrieRoutesTest.groovy new file mode 100644 index 0000000..a445329 --- /dev/null +++ b/core/src/test/groovy/spotty/server/router/TrieRoutesTest.groovy @@ -0,0 +1,275 @@ +package spotty.server.router + +import spock.lang.Specification +import spotty.common.router.route.RouteEntry + +import java.util.function.Consumer + +import static spotty.common.http.HttpMethod.GET + +class TrieRoutesTest extends Specification { + private def routes = new TrieRoutes() + + def "should find route successfully"() { + given: + registerRoute(route) + + when: + def foundNode = routes.findRouteNode(searchPath) + def actual = foundNode != null + + then: + actual == expected + + where: + route | searchPath | expected + "/user/:id" | "/user/12" | true + "/user/:id" | "/user/12/" | true + "/user/:id" | "/user/12/sad" | true + "/user/:id/" | "/user/12" | false + "/user/:id/" | "/user/12/" | true + "/user/:id/" | "/user/12/sad" | false + "/category" | "/category" | true + "/category" | "/category1" | false + "/category" | "/cate" | false + "/category" | "cate" | false + "/:any" | "/any/path" | true + "/*" | "/any/path" | true + "/:any/" | "/any/path" | false + "/user/:name/category/:cat_id" | "/user/alex/category/123" | true + "/user/:name/category/:cat_id" | "/use/alex/category/123" | false + "/user/:name/category/:cat_id" | "/user/alex/cat/123" | false + "/user/:name/category/:cat_id" | "/user/alex" | false + "/user/:name/category/:cat_id" | "/alex/category/123" | false + } + + def "should found correct route when registered a few similar routes"() { + given: + def name = registerRoute("/user/:name") + def nameId = registerRoute("/user/:name/:id") + def nameIdHello = registerRoute("/user/:name/:id/hello") + + when: + def nameFound = routes.findRouteNode("/user/alex") + def nameNotFound = routes.findRouteNode("/user/alex/") + def nameIdFound = routes.findRouteNode("/user/alex/1") + def nameIdHelloFound = routes.findRouteNode("/user/alex/1/hello") + + then: + name == nameFound + nameNotFound == null + nameId == nameIdFound + nameIdHello == nameIdHelloFound + } + + def "should remove correct route"() { + when: + registerRoute("/user") + registerRoute("/user/:id") + registerRoute("/user/:id/") + registerRoute("/user/:id/*") + registerRoute("/user/:id/:name") + registerRoute("/user/:id/:name/alex") + + then: + routes.removeExactly("/user") == true + routes.removeExactly("/user/") == false + routes.removeExactly("/us") == false + routes.removeExactly("/") == false + routes.removeExactly("/user/*") == true + routes.removeExactly("/user/*") == false + routes.removeExactly("/user/*/") == true + routes.removeExactly("/user/*/*") == true + routes.root.children.size() > 0 + routes.removeExactly("/user/*/*/alex") == true + routes.root.children.size() == 0 + } + + def "should remove route, but not children"() { + given: + registerRoute("/a") + registerRoute("/ab") + registerRoute("/ac") + registerRoute("/ad") + registerRoute("/a/q") + registerRoute("/a/e") + registerRoute("/a/c") + + when: + routes.removeExactly("/a") + + then: + { + def node = find("/a") + node.isRoute == false + node.children.size() == 4 + } + + when: + routes.removeExactly("/a/") + + then: + { + def node = find("/a/") + node.isRoute == false + node.children.size() == 3 + } + + when: + def found = find("/a/q") + routes.removeExactly("/a/q") + + then: + { + found != null + find("/a/q") == null + find("/a/").children.size() == 2 + } + + then: + routes.removeExactly("/a") == false + routes.removeExactly("/ab") == true + routes.root.children.size() > 0 + routes.removeExactly("/ac") == true + routes.removeExactly("/ad") == true + routes.removeExactly("/a/q") == false + routes.root.children.size() > 0 + routes.removeExactly("/a/e") == true + routes.root.children.size() > 0 + routes.removeExactly("/a/c") == true + routes.root.children.size() == 0 + } + + def "should set node as route in the middle of tree"() { + when: + registerRoute("/name/alex") + + then: + find("/name").isRoute == false + find("/name/alex").isRoute == true + + when: + registerRoute("/name") + + then: + find("/name").isRoute == true + } + + def "should for each by all routes"() { + given: + registerRoute("/") + registerRoute("/name") + registerRoute("/name/:id") + registerRoute("/name/:id/category") + registerRoute("/name/:id/product") + + var Consumer consumer = Mock() + + when: + routes.forEachRouteIf({ true }, consumer) + + then: + 5 * consumer.accept(_) + } + + def "should for each by all routes by predicate"() { + given: + registerRoute("/") + registerRoute("/name") + registerRoute("/name/:id") + registerRoute("/name/:id/category") + registerRoute("/name/:id/product") + + var Consumer consumer = Mock() + + when: + routes.forEachRouteIf({ it.pathTemplate().startsWith("/name/:id") }, consumer) + + then: + 3 * consumer.accept({ it.pathTemplate().startsWith("/name/:id") }) + } + + def "should not for each by any when predicate returns false"() { + given: + registerRoute("/") + registerRoute("/name") + registerRoute("/name/:id") + registerRoute("/name/:id/category") + registerRoute("/name/:id/product") + + var Consumer consumer = Mock() + + when: + routes.forEachRouteIf({ false }, consumer) + + then: + 0 * consumer.accept(_) + } + + def "should find correct route when registered a few similar"() { + given: + registerRoute("/hello") + registerRoute("/hello/:name") + registerRoute("/hello/*/world") + registerRoute("/hello/:id/:category/world") + registerRoute("/hello/:id/:category/world2") + registerRoute("/api/*") + registerRoute("/*") + + when: + def actual = routes.findRouteNode(path)?.pathNormalized + + then: + actual == expected + + where: + path | expected + "/hello" | "/hello" + "/hello/alex" | "/hello/*" + "/hello/alex/world" | "/hello/*/world" + "/hello/123/category/world" | "/hello/*/*/world" + "/hello/123/category/world2" | "/hello/*/*/world2" + "/hello/123/category/world3" | "/*" + "/api/any" | "/api/*" + "/any/path" | "/*" + "/" | null + } + + def "should clear all routes"() { + given: + registerRoute("/hello") + registerRoute("/hello/:name") + registerRoute("/hello/*/world") + + when: + def routesCount = routes.toNormalizedPaths().size() + + then: + routesCount == 3 + + when: + routes.clear() + routesCount = routes.toNormalizedPaths().size() + + then: + routesCount == 0 + } + + private RouteNode registerRoute(String path) { + def route = RouteEntryFactory.create(path, GET, "*/*", { "" }) + return routes.add(route.pathNormalized(), [(GET): ["*/*": route]]) + } + + private RouteNode find(String path) { + def node = routes.root + for (i in 0..