diff --git a/avaje-jex/src/main/java/io/avaje/jex/Context.java b/avaje-jex/src/main/java/io/avaje/jex/Context.java index 9c05be3c..4b2f2ba9 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/Context.java +++ b/avaje-jex/src/main/java/io/avaje/jex/Context.java @@ -421,6 +421,9 @@ default Context headers(Map headers) { */ BasicAuthCredentials basicAuthCredentials(); + /** Return true if the response has been sent. */ + boolean responseSent(); + /** * This interface represents a cookie used in HTTP communication. Cookies are small pieces of data * sent from a server to a web browser and stored on the user's computer. They can be used to diff --git a/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java b/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java index e0005fc2..cfb34839 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java +++ b/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java @@ -387,6 +387,11 @@ public Context write(InputStream is) { return this; } + @Override + public boolean responseSent() { + return exchange.getResponseCode() != -1; + } + int statusCode() { return statusCode == 0 ? 200 : statusCode; } diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/MultiHandler.java b/avaje-jex/src/main/java/io/avaje/jex/routes/MultiHandler.java new file mode 100644 index 00000000..a44b7d66 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/MultiHandler.java @@ -0,0 +1,25 @@ +package io.avaje.jex.routes; + +import io.avaje.jex.Context; +import io.avaje.jex.ExchangeHandler; + +import java.io.IOException; + +final class MultiHandler implements ExchangeHandler { + + private final ExchangeHandler[] handlers; + + MultiHandler(ExchangeHandler[] handlers) { + this.handlers = handlers; + } + + @Override + public void handle(Context ctx) throws IOException { + for (ExchangeHandler handler : handlers) { + handler.handle(ctx); + if (ctx.responseSent()) { + break; + } + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java index 25a08e49..cd1a9e40 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java @@ -20,6 +20,12 @@ final class RouteEntry implements SpiRoutes.Entry { this.roles = roles; } + @Override + public SpiRoutes.Entry multiHandler(ExchangeHandler[] handlers) { + final var handler = new MultiHandler(handlers); + return new RouteEntry(path, handler, roles); + } + @Override public void inc() { active.incrementAndGet(); diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java index a26e90c6..c7df775a 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java @@ -1,6 +1,5 @@ package io.avaje.jex.routes; -import java.util.ArrayList; import java.util.List; final class RouteIndex { @@ -8,29 +7,27 @@ final class RouteIndex { /** * Partition entries by the number of path segments. */ - private final RouteIndex.Entry[] entries = new RouteIndex.Entry[6]; + private final IndexEntry[] entries; /** * Wildcard/splat based route entries. */ - private final List wildcardEntries = new ArrayList<>(); + private final SpiRoutes.Entry[] wildcardEntries; - RouteIndex() { - for (int i = 0; i < entries.length; i++) { - entries[i] = new RouteIndex.Entry(); - } + RouteIndex(List wildcards, List> pathEntries) { + this.wildcardEntries = wildcards.toArray(new SpiRoutes.Entry[0]); + this.entries = pathEntries.stream() + .map(RouteIndex::toEntry) + .toList() + .toArray(new IndexEntry[0]); } - private int index(int segmentCount) { - return Math.min(segmentCount, 5); + private static IndexEntry toEntry(List routeEntries) { + return new IndexEntry(routeEntries.toArray(new SpiRoutes.Entry[0])); } - void add(SpiRoutes.Entry entry) { - if (entry.multiSlash()) { - wildcardEntries.add(entry); - } else { - entries[index(entry.segmentCount())].add(entry); - } + private int index(int segmentCount) { + return Math.min(segmentCount, 5); } SpiRoutes.Entry match(String pathInfo) { @@ -63,7 +60,7 @@ private int segmentCount(String pathInfo) { long activeRequests() { long total = 0; - for (RouteIndex.Entry entry : entries) { + for (IndexEntry entry : entries) { total += entry.activeRequests(); } for (SpiRoutes.Entry entry : wildcardEntries) { @@ -72,21 +69,16 @@ long activeRequests() { return total; } - private static class Entry { + private static final class IndexEntry { - private final List list = new ArrayList<>(); + private final SpiRoutes.Entry[] pathEntries; - void add(SpiRoutes.Entry entry) { - if (entry.literal()) { - // add literal paths to the beginning - list.add(0, entry); - } else { - list.add(entry); - } + IndexEntry(SpiRoutes.Entry[] pathEntries) { + this.pathEntries = pathEntries; } SpiRoutes.Entry match(String pathInfo) { - for (SpiRoutes.Entry entry : list) { + for (SpiRoutes.Entry entry : pathEntries) { if (entry.matches(pathInfo)) { return entry; } @@ -96,7 +88,7 @@ SpiRoutes.Entry match(String pathInfo) { long activeRequests() { long total = 0; - for (SpiRoutes.Entry entry : list) { + for (SpiRoutes.Entry entry : pathEntries) { total += entry.activeRequests(); } return total; diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndexBuild.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndexBuild.java new file mode 100644 index 00000000..bb7b7674 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndexBuild.java @@ -0,0 +1,85 @@ +package io.avaje.jex.routes; + +import io.avaje.jex.ExchangeHandler; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Build the RouteIndex. + */ +final class RouteIndexBuild { + + /** + * Partition entries by the number of path segments. + */ + private final RouteIndexBuild.Entry[] entries = new RouteIndexBuild.Entry[6]; + + /** + * Wildcard/splat based route entries. + */ + private final List wildcardEntries = new ArrayList<>(); + + RouteIndexBuild() { + for (int i = 0; i < entries.length; i++) { + entries[i] = new RouteIndexBuild.Entry(); + } + } + + private int index(int segmentCount) { + return Math.min(segmentCount, 5); + } + + void add(SpiRoutes.Entry entry) { + if (entry.multiSlash()) { + wildcardEntries.add(entry); + } else { + entries[index(entry.segmentCount())].add(entry); + } + } + + /** + * Build and return the RouteIndex. + */ + RouteIndex build() { + final List> pathEntries = new ArrayList<>(); + for (Entry entry : entries) { + pathEntries.add(entry.build()); + } + return new RouteIndex(wildcardEntries, pathEntries); + } + + private static class Entry { + + private final List list = new ArrayList<>(); + private final Map> pathMap = new HashMap<>(); + + void add(SpiRoutes.Entry entry) { + if (entry.literal()) { + // add literal paths to the beginning + list.addFirst(entry); + } else { + pathMap.computeIfAbsent(entry.matchPath(), k -> new ArrayList<>(2)).add(entry); + } + } + + List build() { + List result = new ArrayList<>(list.size() + pathMap.size()); + result.addAll(list); + pathMap.values().forEach(pathList -> { + if (pathList.size() == 1) { + result.add(pathList.getFirst()); + } else { + ExchangeHandler[] handlers = pathList.stream() + .map(SpiRoutes.Entry::handler) + .toList() + .toArray(new ExchangeHandler[0]); + result.add(pathList.getFirst().multiHandler(handlers)); + } + }); + return result; + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java index 5e9de0f0..b05ef4c6 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java @@ -14,9 +14,11 @@ public final class RoutesBuilder { public RoutesBuilder(Routing routing, boolean ignoreTrailingSlashes) { this.ignoreTrailingSlashes = ignoreTrailingSlashes; + final var buildMap = new EnumMap(Routing.Type.class); for (var handler : routing.handlers()) { - typeMap.computeIfAbsent(handler.getType(), h -> new RouteIndex()).add(convert(handler)); + buildMap.computeIfAbsent(handler.getType(), h -> new RouteIndexBuild()).add(convert(handler)); } + buildMap.forEach((key, value) -> typeMap.put(key, value.build())); filters = List.copyOf(routing.filters()); } diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java b/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java index 86b7c832..7a96f9fb 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java @@ -1,11 +1,9 @@ package io.avaje.jex.routes; -import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Set; -import io.avaje.jex.Context; import io.avaje.jex.ExchangeHandler; import io.avaje.jex.HttpFilter; import io.avaje.jex.Routing; @@ -103,6 +101,9 @@ interface Entry { /** Return the authentication roles for the route. */ Set roles(); + + /** Create and return a new Entry with multiple handlers. */ + Entry multiHandler(ExchangeHandler[] handlers); } } diff --git a/avaje-jex/src/test/java/io/avaje/jex/jdk/MultiHandlerTest.java b/avaje-jex/src/test/java/io/avaje/jex/jdk/MultiHandlerTest.java new file mode 100644 index 00000000..3ac050e0 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/jdk/MultiHandlerTest.java @@ -0,0 +1,78 @@ +package io.avaje.jex.jdk; + +import io.avaje.jex.Jex; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import java.net.http.HttpResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +class MultiHandlerTest { + + static TestPair pair = init(); + + static TestPair init() { + Jex app = Jex.create() + .routing(routing -> routing + .get("/hi", ctx4 -> { + if (ctx4.header("Hx-Request") != null) { + ctx4.text("HxResponse"); + } + }) + .get("/hi", ctx -> ctx.text("NormalResponse")) + .get("/hi/{id}", ctx3 -> { + if (ctx3.header("Hx-Request") != null) { + ctx3.text("HxResponse|" + ctx3.pathParam("id")); + } + }) + .get("/hi/{id}", ctx2 -> { + if (ctx2.header("H2-Request") != null) { + ctx2.text("H2Response|" + ctx2.pathParam("id")); + } + }) + .get("/hi/{id}", ctx1 -> ctx1.text("NormalResponse|" + ctx1.pathParam("id"))) + ); + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void test() { + HttpResponse hres = pair.request().path("hi").GET().asString(); + assertThat(hres.statusCode()).isEqualTo(200); + assertThat(hres.body()).isEqualTo("NormalResponse"); + + HttpResponse hxRes = pair.request() + .header("Hx-Request", "true") + .path("hi") + .GET().asString(); + assertThat(hxRes.statusCode()).isEqualTo(200); + assertThat(hxRes.body()).isEqualTo("HxResponse"); + } + + @Test + void testWithPathParam() { + HttpResponse hres = pair.request().path("hi/42").GET().asString(); + assertThat(hres.statusCode()).isEqualTo(200); + assertThat(hres.body()).isEqualTo("NormalResponse|42"); + + HttpResponse hxRes = pair.request() + .header("Hx-Request", "true") + .path("hi/42") + .GET().asString(); + assertThat(hxRes.statusCode()).isEqualTo(200); + assertThat(hxRes.body()).isEqualTo("HxResponse|42"); + + HttpResponse h2Res = pair.request() + .header("H2-Request", "true") + .path("hi/42") + .GET().asString(); + assertThat(h2Res.statusCode()).isEqualTo(200); + assertThat(h2Res.body()).isEqualTo("H2Response|42"); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java b/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java index 5db0e53e..674bc734 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java @@ -5,45 +5,57 @@ import java.util.Set; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import io.avaje.jex.Routing; class RouteIndexTest { - private static final Routing.Entry routingEntry = Mockito.mock(Routing.Entry.class); - @Test void match() { - RouteIndex index = new RouteIndex(); - index.add(entry("/")); - index.add(entry("/a/b/c")); - index.add(entry("/a/b/d")); - index.add(entry("/a/b/d/e")); - index.add(entry("/a/b/d/e/f")); - index.add(entry("/a/b/d/e/f/g")); - index.add(entry("/a/b/d/e/f/g/h")); - index.add(entry("/a/b/d/e/f/g2/h")); + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/")); + indexBuild.add(entry("/a/b/c")); + indexBuild.add(entry("/a/b/d")); + indexBuild.add(entry("/a/b/d/e")); + indexBuild.add(entry("/a/b/d/e/f")); + indexBuild.add(entry("/a/b/d/e/f/g")); + indexBuild.add(entry("/a/b/d/e/f/g/h")); + indexBuild.add(entry("/a/b/d/e/f/g2/h")); + + RouteIndex index = indexBuild.build(); assertThat(index.match("/").matchPath()).isEqualTo("/"); assertThat(index.match("/a/b/d/e/f/g2/h").matchPath()).isEqualTo("/a/b/d/e/f/g2/h"); } + @Test + void matchMulti() { + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/hi/{id}")); + indexBuild.add(entry("/a/b/c")); + indexBuild.add(entry("/hi/{id}")); + indexBuild.add(entry("/b")); + + RouteIndex index = indexBuild.build(); + + SpiRoutes.Entry entry = index.match("/hi/42"); + assertThat(entry).isNotNull(); + } + @Test void match_args() { - RouteIndex index = new RouteIndex(); - index.add(entry("/")); - index.add(entry("/{id}")); - index.add(entry("/{id}/a")); - index.add(entry("/{id}/b")); - index.add(entry("/a/{id}/c")); - index.add(entry("/a/{name}/d")); - index.add(entry("/a/b/d/e")); - index.add(entry("/a/b/d/e/f")); - index.add(entry("/a/b/d/e/f/g")); - index.add(entry("/a/b/d/e/f/g/h")); - index.add(entry("/a/b/d/e/f/g2/h")); + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/")); + indexBuild.add(entry("/{id}")); + indexBuild.add(entry("/{id}/a")); + indexBuild.add(entry("/{id}/b")); + indexBuild.add(entry("/a/{id}/c")); + indexBuild.add(entry("/a/{name}/d")); + indexBuild.add(entry("/a/b/d/e")); + indexBuild.add(entry("/a/b/d/e/f")); + indexBuild.add(entry("/a/b/d/e/f/g")); + indexBuild.add(entry("/a/b/d/e/f/g/h")); + indexBuild.add(entry("/a/b/d/e/f/g2/h")); + var index = indexBuild.build(); assertThat(index.match("/").matchPath()).isEqualTo("/"); assertThat(index.match("/42").matchPath()).isEqualTo("/{id}"); assertThat(index.match("/99").matchPath()).isEqualTo("/{id}"); @@ -54,12 +66,13 @@ void match_args() { @Test void match_splat() { - RouteIndex index = new RouteIndex(); - index.add(entry("/")); - index.add(entry("/{id}")); - index.add(entry("/{id}/a")); - index.add(entry("/{id}/*")); + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/")); + indexBuild.add(entry("/{id}")); + indexBuild.add(entry("/{id}/a")); + indexBuild.add(entry("/{id}/*")); + var index = indexBuild.build(); assertThat(index.match("/").matchPath()).isEqualTo("/"); assertThat(index.match("/42").matchPath()).isEqualTo("/{id}"); assertThat(index.match("/42/a").matchPath()).isEqualTo("/{id}/a"); @@ -68,6 +81,7 @@ void match_splat() { } private SpiRoutes.Entry entry(String path) { - return new RouteEntry(new PathParser(path, true), routingEntry.getHandler(), Set.of()); + return new RouteEntry(new PathParser(path, true), null, Set.of()); } + }