diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 5fd9971..d4738e7 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -6,8 +6,15 @@ + - + + + + + + + - + + - @@ -226,7 +233,9 @@ diff --git a/README.md b/README.md index 67bfceb..1aa784d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The third parameter of `addRoute` is the endpoint. For the sake of brevity we wi number for each request instead of looking up the user's actual age in a database. ### Process Inbound Requests ```java -final Optional>> result = router.route(HttpMethod.GET, "/users/duncpro/age"); +final Optional>> result = router.route(HttpMethod.GET, "/users/duncpro/age").asOptional(); ``` An empty optional is returned if no route matches the given path. In this case there is obviously a match since we just registered a route for this path above. In practice you should present a 404 page if a path @@ -74,5 +74,7 @@ assertEquals(Map.of("customerId", "duncan", "orderId", "abc123"), pathArguments) assertEquals(Route("/customers/*/orders/*"), route); ``` + + ## More Docs There is a Javadoc for this library [here](https://duncpro.github.io/JRoute). diff --git a/build.gradle.kts b/build.gradle.kts index 348c2cb..09c30c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.duncpro" -version = "1.0-SNAPSHOT-7" +version = "1.0-SNAPSHOT-8" repositories { mavenCentral() diff --git a/src/main/java/com/duncpro/jroute/router/RouteTreeNode.java b/src/main/java/com/duncpro/jroute/router/RouteTreeNode.java index bcf0f91..a7dd23b 100644 --- a/src/main/java/com/duncpro/jroute/router/RouteTreeNode.java +++ b/src/main/java/com/duncpro/jroute/router/RouteTreeNode.java @@ -89,17 +89,8 @@ void addEndpoint(HttpMethod method, E endpoint) { if (prev != null) throw new IllegalStateException("PositionedEndpoint already bound to method: " + method.name()); } - Optional> getEndpointAsRouterResult(HttpMethod method) { - final var endpoint = endpoints.get(method); - - if (endpoint == null) return Optional.empty(); - - final var result = new RouterResult<>( - endpoint, - position.getRoute() - ); - - return Optional.of(result); + Optional getEndpoint(HttpMethod method) { + return Optional.ofNullable(endpoints.get(method)); } } diff --git a/src/main/java/com/duncpro/jroute/router/Router.java b/src/main/java/com/duncpro/jroute/router/Router.java index 1b78ec7..88d81bc 100644 --- a/src/main/java/com/duncpro/jroute/router/Router.java +++ b/src/main/java/com/duncpro/jroute/router/Router.java @@ -5,24 +5,20 @@ import com.duncpro.jroute.RouteConflictException; import com.duncpro.jroute.route.Route; -import java.util.List; -import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; public interface Router { - default Optional> route(HttpMethod method, String pathString) { + default RouterResult route(HttpMethod method, String pathString) { return route(method, new Path(pathString)); } /** * Finds the {@link Route} and endpoint ({@link E}) which has been assigned responsibility for requests made to the * given {@link Path} using the given {@link HttpMethod}. Routes must be registered with the router in advance using - * {@link #addRoute(HttpMethod, String, Object)} or similar. If no route exists for the given method and path then + * {@link #add(HttpMethod, String, Object)} or similar. If no route exists for the given method and path then * an empty optional is returned instead. */ - Optional> route(HttpMethod method, Path path); + RouterResult route(HttpMethod method, Path path); /** * Assigns the given endpoint responsibility for requests made with the given {@link HttpMethod} @@ -39,11 +35,11 @@ default Optional> route(HttpMethod method, String pathString) { * @throws RouteConflictException if the given {@code routeString} overlaps another pre-existing route with the same * {@link HttpMethod}. The route will not be overwritten. */ - default void addRoute(HttpMethod method, String routeString, E endpoint) throws RouteConflictException { - addRoute(method, new Route(routeString), endpoint); + default void add(HttpMethod method, String routeString, E endpoint) throws RouteConflictException { + add(method, new Route(routeString), endpoint); } - void addRoute(HttpMethod method, Route route, E endpoint) throws RouteConflictException; + void add(HttpMethod method, Route route, E endpoint) throws RouteConflictException; /** * Returns a set of {@link PositionedEndpoint}s which are accessible via the given {@link Route}. @@ -55,7 +51,7 @@ default void addRoute(HttpMethod method, String routeString, E endpoint) throws /** * @throws RouteConflictException if the given endpoint conflicts with a pre-existing endpoint within the router. */ - default void addRoute(PositionedEndpoint positionedEndpoint) { - addRoute(positionedEndpoint.method, positionedEndpoint.route, positionedEndpoint.endpoint); + default void add(PositionedEndpoint positionedEndpoint) { + add(positionedEndpoint.method, positionedEndpoint.route, positionedEndpoint.endpoint); } } diff --git a/src/main/java/com/duncpro/jroute/router/RouterResult.java b/src/main/java/com/duncpro/jroute/router/RouterResult.java index 0479b04..052d251 100644 --- a/src/main/java/com/duncpro/jroute/router/RouterResult.java +++ b/src/main/java/com/duncpro/jroute/router/RouterResult.java @@ -1,27 +1,75 @@ package com.duncpro.jroute.router; +import com.duncpro.jroute.HttpMethod; import com.duncpro.jroute.route.Route; import net.jcip.annotations.Immutable; -@Immutable +import java.util.Optional; + +/** + * There are three district types of {@link RouterResult}. + * 1. {@link Matched}, representing a successful match of a request to an endpoint. + * 2. {@link ResourceNotFound}, representing a failure to match the path to a resource. + * 3. {@link MethodNotAllowed}, representing a successful match of a request to a resource but a failure + * to match and the request method (http verb) to an endpoint on the resource. + */ +@SuppressWarnings("unused") // The type parameter is used! Quit complaining IntelliJ. public class RouterResult { - private final E endpoint; - private final Route route; + /** + * {@link RouterResult} which indicates a failure to match the given path with any registered route. + * This class represents an HTTP 404 error. See also {@link MethodNotAllowed}, which represents the case + * where the route exists but the method does not. + */ + public static final class ResourceNotFound extends RouterResult { + ResourceNotFound() {} + } - public RouterResult(E endpoint, Route route) { - this.endpoint = endpoint; - this.route = route; + /** + * {@link RouterResult} representing the case where the given path matched a resource within the + * {@link Router}, but the given {@link HttpMethod} is not defined for the resource. + * This result is associated with HTTP Status Code 405. + */ + public static final class MethodNotAllowed extends RouterResult { + MethodNotAllowed() {} } - public E getEndpoint() { - return endpoint; + /** + * {@link RouterResult} representing the case where the given patch matched a resource within the + * {@link Router}, and the given {@link HttpMethod} is defined on that resource. + */ + @Immutable + public static final class Matched extends RouterResult { + private final E endpoint; + private final Route route; + + Matched(E endpoint, Route route) { + this.endpoint = endpoint; + this.route = route; + } + + public E getEndpoint() { + return endpoint; + } + + /** + * Returns the route matching the path. The returned {@link Route} object can be used to extract path arguments + * for route parameters. See {@link Route#extractVariables(String)}. + */ + public Route getRoute() { + return route; + } } + private RouterResult() {} + /** - * Returns the route matching the path. The returned {@link Route} object can be used to extract path arguments - * for route parameters. See {@link Route#extractVariables(String)}. + * If this {@link RouterResult} is a successful matched (i.e. instanceof {@link Matched}), + * then this method returns an optional containing this object but cast to {@link Matched}. + * If this {@link RouterResult} is an error type, such as {@link ResourceNotFound} or {@link MethodNotAllowed}, + * then this method returns an empty optional. */ - public Route getRoute() { - return route; + public Optional> asOptional() { + if (this instanceof Matched) return Optional.of((Matched) this); + return Optional.empty(); } } diff --git a/src/main/java/com/duncpro/jroute/router/TreeRouter.java b/src/main/java/com/duncpro/jroute/router/TreeRouter.java index 1a70f66..0b0f365 100644 --- a/src/main/java/com/duncpro/jroute/router/TreeRouter.java +++ b/src/main/java/com/duncpro/jroute/router/TreeRouter.java @@ -14,13 +14,16 @@ public class TreeRouter implements Router { private final RouteTreeNode rootRoute = new RouteTreeNode<>(RouteTreeNodePosition.root()); @Override - public Optional> route(HttpMethod method, Path path) { + public RouterResult route(HttpMethod method, Path path) { return findNode(rootRoute, path) - .flatMap(node -> node.getEndpointAsRouterResult(method)); + .map(node -> node.getEndpoint(method) + .>map(endpoint -> new RouterResult.Matched<>(endpoint, node.position.getRoute())) + .orElse(new RouterResult.MethodNotAllowed<>())) + .orElse(new RouterResult.ResourceNotFound<>()); } @Override - public void addRoute(HttpMethod method, Route route, E endpoint) throws RouteConflictException { + public void add(HttpMethod method, Route route, E endpoint) throws RouteConflictException { final var node = findOrCreateNode(rootRoute, route); node.addEndpoint(method, endpoint); } diff --git a/src/test/java/com/duncpro/jroute/PathTest.java b/src/test/java/com/duncpro/jroute/PathTest.java index 3042e9d..c53419a 100644 --- a/src/test/java/com/duncpro/jroute/PathTest.java +++ b/src/test/java/com/duncpro/jroute/PathTest.java @@ -1,7 +1,5 @@ package com.duncpro.jroute; -import com.duncpro.jroute.route.Route; -import com.duncpro.jroute.route.StaticRouteElement; import org.junit.jupiter.api.Test; import java.util.List; diff --git a/src/test/java/com/duncpro/jroute/ExampleUsage.java b/src/test/java/com/duncpro/jroute/SmokeTest.java similarity index 53% rename from src/test/java/com/duncpro/jroute/ExampleUsage.java rename to src/test/java/com/duncpro/jroute/SmokeTest.java index 7f2229d..5553c6d 100644 --- a/src/test/java/com/duncpro/jroute/ExampleUsage.java +++ b/src/test/java/com/duncpro/jroute/SmokeTest.java @@ -2,16 +2,15 @@ import com.duncpro.jroute.router.Router; import com.duncpro.jroute.router.TreeRouter; +import com.duncpro.jroute.router.RouterResult; import org.junit.jupiter.api.Test; import java.util.List; -import java.util.function.BiConsumer; import java.util.function.BiFunction; -import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; -public class ExampleUsage { +public class SmokeTest { private static class Request { String path; } @@ -21,7 +20,7 @@ public void example() { final Router, Integer>> router = new TreeRouter<>(); // On application startup, register HTTP request handlers with the router. - router.addRoute(HttpMethod.GET, "/calculator/add/*/*", (request, pathArgs) -> { + router.add(HttpMethod.GET, "/calculator/add/*/*", (request, pathArgs) -> { final var x = Integer.parseInt(pathArgs.get(0)); final var y = Integer.parseInt(pathArgs.get(1)); return x + y; @@ -32,14 +31,20 @@ public void example() { fakeIncomingRequest.path = "/calculator/add/6/4"; // Resolve the HTTP request handler based on the path in the URL. - final var routerResult = router.route(HttpMethod.GET, fakeIncomingRequest.path) - .orElseThrow(); + final RouterResult, Integer>> routerResult = router + .route(HttpMethod.GET, fakeIncomingRequest.path); - // Extract the variable path elements so they may be used by the request handler. - final var pathArgs = routerResult.getRoute().extractVariables(fakeIncomingRequest.path); + if (routerResult instanceof RouterResult.MethodNotAllowed) throw new AssertionError(); + if (routerResult instanceof RouterResult.ResourceNotFound) throw new AssertionError(); + if (!(routerResult instanceof RouterResult.Matched)) throw new AssertionError(); + + final var match = (RouterResult.Matched, Integer>>) routerResult; + + // Extract the variable path elements, so they may be used by the request handler. + final var pathArgs = match.getRoute().extractVariables(fakeIncomingRequest.path); // Invoke the request handler and pass in the variables. - final var response = routerResult.getEndpoint().apply(fakeIncomingRequest, pathArgs); + final var response = match.getEndpoint().apply(fakeIncomingRequest, pathArgs); // Make sure our calculator endpoint is working properly assertEquals(10, response); diff --git a/src/test/java/com/duncpro/jroute/router/TreeRouterTest.java b/src/test/java/com/duncpro/jroute/router/TreeRouterTest.java index 12c4d77..464525e 100644 --- a/src/test/java/com/duncpro/jroute/router/TreeRouterTest.java +++ b/src/test/java/com/duncpro/jroute/router/TreeRouterTest.java @@ -6,8 +6,6 @@ import com.duncpro.jroute.route.StaticRouteElement; import org.junit.jupiter.api.Test; -import java.util.Set; - import static org.junit.jupiter.api.Assertions.*; class TreeRouterTest { @@ -23,9 +21,8 @@ void createAndLookupRoute() { final var actualEndpoint = tree .getOrCreateChildRoute(new StaticRouteElement("hello")) .getOrCreateChildRoute(new StaticRouteElement("world")) - .getEndpointAsRouterResult(HttpMethod.GET) - .orElseThrow() - .getEndpoint(); + .getEndpoint(HttpMethod.GET) + .orElseThrow(); assertEquals(expectedEndpoint, actualEndpoint); } @@ -36,9 +33,10 @@ void resolveNormalPath() { final var expectedEndpoint = 1; - router.addRoute(HttpMethod.GET, "/hello/world", expectedEndpoint); + router.add(HttpMethod.GET, "/hello/world", expectedEndpoint); final var actualEndpoint = router.route(HttpMethod.GET, "/hello/world") + .asOptional() .orElseThrow() .getEndpoint(); @@ -51,9 +49,10 @@ void resolveWildcardPath() { final var expectedEndpoint = 10; - router.addRoute(HttpMethod.GET, "/users/*/pets/*/age", expectedEndpoint); + router.add(HttpMethod.GET, "/users/*/pets/*/age", expectedEndpoint); final var actualEndpoint = router.route(HttpMethod.GET, "/users/duncan/pets/cocoa/age") + .asOptional() .orElseThrow() .getEndpoint(); @@ -64,30 +63,40 @@ void resolveWildcardPath() { void unresolvablePath() { final var router = new TreeRouter<>(); - router.addRoute(HttpMethod.GET, "/users", 0); + router.add(HttpMethod.GET, "/users", 0); + + final var result = router.route(HttpMethod.POST, "/settings"); + + assertTrue(result instanceof RouterResult.ResourceNotFound); + } + + @Test + void unresolvableMethod() { + final var router = new TreeRouter<>(); - final var endpoint = router.route(HttpMethod.POST, "/settings"); + router.add(HttpMethod.GET, "/users", 0); - assertTrue(endpoint.isEmpty()); + final var result = router.route(HttpMethod.POST, "/users"); + assertTrue(result instanceof RouterResult.MethodNotAllowed); } @Test void conflictWildcardAfterStaticRoute() { final var router = new TreeRouter<>(); - router.addRoute(HttpMethod.GET, "/users/duncan", new Object()); + router.add(HttpMethod.GET, "/users/duncan", new Object()); assertThrows(RouteConflictException.class, () -> { - router.addRoute(HttpMethod.GET, "/users/*", new Object()); + router.add(HttpMethod.GET, "/users/*", new Object()); }); } @Test void conflictStaticRouteAfterWildcard() { final var router = new TreeRouter<>(); - router.addRoute(HttpMethod.GET, "/users/*", new Object()); + router.add(HttpMethod.GET, "/users/*", new Object()); assertThrows(RouteConflictException.class, () -> { - router.addRoute(HttpMethod.GET, "/users/duncan", new Object()); + router.add(HttpMethod.GET, "/users/duncan", new Object()); }); } @@ -96,9 +105,10 @@ void routeResultHasCorrectRoute() { final var router = new TreeRouter<>(); final var expected = new Route("/users/*/devices"); - router.addRoute(HttpMethod.GET, expected.toString(), new Object()); + router.add(HttpMethod.GET, expected.toString(), new Object()); final var actual = router.route(HttpMethod.GET, "/users/duncan/devices") + .asOptional() .orElseThrow() .getRoute(); @@ -112,8 +122,8 @@ void getAllEndpoints() { final var expectedEndpoint = new PositionedEndpoint<>(new Route("/hello"), HttpMethod.GET, 1); final var expectedNestedEndpoint = new PositionedEndpoint<>(new Route("/hello/world"), HttpMethod.GET, 2); - router.addRoute(expectedEndpoint); - router.addRoute(expectedNestedEndpoint); + router.add(expectedEndpoint); + router.add(expectedNestedEndpoint); { final var resolvedEndpoints = router.getAllEndpoints(new Route("/hello/world"));