PathTemplateRouterTest.java
/*
* JBoss, Home of Professional Open Source.
* Copyright 2025 Red Hat, Inc., and individual contributors
* as indicated by the @author tags.
*
* 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 io.undertow.util;
import io.undertow.testutils.category.UnitTest;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.junit.Assert;
import org.junit.Test;
import org.junit.experimental.categories.Category;
/**
* Some tests that were specifically used to test during the development of {@link PathTemplateRouterFactory} as well as an
* adaptation of the tests in {@link PathTemplateTestCase} to confirm compatibility with {@link PathTemplateMatcher}.
*
* @author Dirk Roets
*/
@Category(UnitTest.class)
public class PathTemplateRouterTest {
private final String defaultTarget = "default";
private static void assertValidTemplate(final String templatePath) {
PathTemplateParser.parseTemplate(templatePath, new Object());
}
@SuppressWarnings("ThrowableResultIgnored")
private static void assertInValidTemplate(final String templatePath) {
Assert.assertThrows(IllegalArgumentException.class,
() -> PathTemplateParser.parseTemplate(templatePath, new Object())
);
}
private static void assertPatternEquals(
final String expectedTemplatePath,
final String actualTemplatePath
) {
final PathTemplateParser.PathTemplate<Object> expectedTemplate = PathTemplateParser.parseTemplate(
expectedTemplatePath, new Object()
);
final PathTemplateParser.PathTemplate<Object> actualTemplate = PathTemplateParser.parseTemplate(
actualTemplatePath, new Object()
);
Assert.assertTrue(expectedTemplate.patternEquals(actualTemplate));
Assert.assertEquals(new PathTemplateParser.PathTemplatePatternEqualsAdapter<>(expectedTemplate),
new PathTemplateParser.PathTemplatePatternEqualsAdapter<>(actualTemplate)
);
}
private static void assertPatternNotEquals(
final String expectedTemplatePath,
final String actualTemplatePath
) {
final PathTemplateParser.PathTemplate<Object> expectedTemplate = PathTemplateParser.parseTemplate(
expectedTemplatePath, new Object()
);
final PathTemplateParser.PathTemplate<Object> actualTemplate = PathTemplateParser.parseTemplate(
actualTemplatePath, new Object()
);
Assert.assertFalse(expectedTemplate.patternEquals(actualTemplate));
Assert.assertNotEquals(new PathTemplateParser.PathTemplatePatternEqualsAdapter<>(expectedTemplate),
new PathTemplateParser.PathTemplatePatternEqualsAdapter<>(actualTemplate)
);
}
/**
* Assert that URL path templates are parsed correctly.
*/
@Test
public void testParseTemplate() {
assertValidTemplate("");
assertValidTemplate("/");
assertValidTemplate("/some");
assertValidTemplate("/some/static/path");
assertValidTemplate("/some/static/path/");
assertValidTemplate("{var1}");
assertValidTemplate("/{var1}");
assertValidTemplate("/{var1}/some/{var2}");
assertValidTemplate("/some/{var1}");
assertValidTemplate("/some/{var1}/");
assertValidTemplate("*");
assertValidTemplate("/*");
assertValidTemplate("/some/*");
assertValidTemplate("/some*");
assertInValidTemplate("/some illegal/path");
assertInValidTemplate("/some/a{var1}");
assertInValidTemplate("/some*a");
assertInValidTemplate("/some/*/path");
assertPatternEquals("/some/static/path", "/some/static/path");
assertPatternNotEquals("some/static/path", "/some/static/path2");
assertPatternEquals("/some/{var1}/path", "/some/{var2}/path");
assertPatternNotEquals("/some/{var1}/path", "/some/{var2}/path2");
assertPatternEquals("/some/{var1}/path*", "/some/{var2}/path*");
}
private static <T> PathTemplateRouterFactory.Builder<Supplier<T>, T> routerBuilder(final T defaultTarget) {
return PathTemplateRouterFactory.Builder.newBuilder().updateDefaultTarget(defaultTarget);
}
private void assertNoMatch(
final PathTemplateRouter<String> router,
final String path
) {
final PathTemplateRouteResult<String> result = router.route(path);
Assert.assertSame(defaultTarget, result.getTarget());
Assert.assertTrue(result.getPathTemplate().isEmpty());
Assert.assertTrue(result.getParameters().isEmpty());
}
private void assertMatch(
final PathTemplateRouter<String> router,
final String path,
final String target,
final String... pathParams
) {
final int pathParamLen = pathParams.length;
if (pathParamLen % 2 == 1) {
throw new IllegalArgumentException();
}
final Map<String, String> expectedParams;
if (pathParamLen > 0) {
expectedParams = new HashMap<>((int) (pathParamLen / 0.75d) + 1);
for (int i = 0; i < pathParamLen; i += 2) {
expectedParams.put(pathParams[i], pathParams[i + 1]);
}
} else {
expectedParams = Collections.emptyMap();
}
final PathTemplateRouteResult<String> result = router.route(path);
Assert.assertSame(target, result.getTarget());
Assert.assertTrue(result.getPathTemplate().isPresent());
Assert.assertEquals(expectedParams, result.getParameters());
}
/**
* Assert that requests are routed correctly.
*/
@Test
public void testRouting() {
final int targetCount = 20;
final String[] targets = new String[targetCount];
for (int i = 0; i < targetCount; i++) {
targets[i] = "target-" + i;
}
PathTemplateRouter<String> router = routerBuilder(defaultTarget)
.addTemplate("/", () -> targets[0])
.addTemplate("/users", () -> targets[1])
.addTemplate("/users/teams", () -> targets[2])
.addTemplate("/users/dashboards", () -> targets[3])
.addTemplate("/users/authentication-methods", () -> targets[4])
.addTemplate("/users/connection-types", () -> targets[5])
.addTemplate("/users/avatars", () -> targets[6])
.addTemplate("/users/{userId}", () -> targets[7])
.addTemplate("/users/{userId}/teams", () -> targets[8])
.addTemplate("/users/{userId}/dashboards", () -> targets[9])
.addTemplate("/users/{userId}/authentication-methods", () -> targets[10])
.addTemplate("/users/{userId}/connection-types", () -> targets[11])
.addTemplate("/users/{userId}/avatars", () -> targets[12])
.addTemplate("/users/properties/*", () -> targets[13])
.addTemplate("/users/properties/internal-*", () -> targets[14])
.addTemplate("/users/{userId}/properties/*", () -> targets[15])
.addTemplate("/users/{userId}/*", () -> targets[16])
.build();
assertNoMatch(router, "/some/other/path");
// Some basic routing requests.
assertNoMatch(router, "/unknown/path");
assertMatch(router, "/", targets[0]);
assertMatch(router, "/users", targets[1]);
assertMatch(router, "/users/teams", targets[2]);
assertMatch(router, "/users/dashboards", targets[3]);
assertMatch(router, "/users/authentication-methods", targets[4]);
assertMatch(router, "/users/connection-types", targets[5]);
assertMatch(router, "/users/avatars", targets[6]);
assertMatch(router, "/users/1234", targets[7], "userId", "1234");
assertMatch(router, "/users/1234/teams", targets[8], "userId", "1234");
assertMatch(router, "/users/1234/dashboards", targets[9], "userId", "1234");
assertMatch(router, "/users/1234/authentication-methods", targets[10], "userId", "1234");
assertMatch(router, "/users/1234/connection-types", targets[11], "userId", "1234");
assertMatch(router, "/users/1234/avatars", targets[12], "userId", "1234");
assertMatch(router, "/users/properties/followers", targets[13], "*", "followers");
assertMatch(router, "/users/properties/internal-previous-password-hashes", targets[14], "*",
"previous-password-hashes");
assertMatch(router, "/users/1234/properties/followers", targets[15], "userId", "1234", "*", "followers");
}
/* =============================================================================================================
Below are tests coppied from PathTemplateTestCase to verify compatibility with the existing path template
matcher.
============================================================================================================= */
@Test
public void testMatches() {
// test normal use
testMatch("/docs/mydoc", "/docs/mydoc");
testMatch("/docs/{docId}", "/docs/mydoc", "docId", "mydoc");
testMatch("/docs/{docId}/{op}", "/docs/mydoc/read", "docId", "mydoc", "op", "read");
testMatch("/docs/{docId}/{op}/{allowed}", "/docs/mydoc/read/true", "docId", "mydoc", "op", "read", "allowed",
"true");
testMatch("/docs/{docId}/operation/{op}", "/docs/mydoc/operation/read", "docId", "mydoc", "op", "read");
testMatch("/docs/{docId}/read", "/docs/mydoc/read", "docId", "mydoc");
testMatch("/docs/{docId}/read", "/docs/mydoc/read?myQueryParam", "docId", "mydoc");
// test no leading slash
testMatch("docs/mydoc", "/docs/mydoc");
testMatch("docs/{docId}", "/docs/mydoc", "docId", "mydoc");
testMatch("docs/{docId}/{op}", "/docs/mydoc/read", "docId", "mydoc", "op", "read");
testMatch("docs/{docId}/{op}/{allowed}", "/docs/mydoc/read/true", "docId", "mydoc", "op", "read", "allowed",
"true");
testMatch("docs/{docId}/operation/{op}", "/docs/mydoc/operation/read", "docId", "mydoc", "op", "read");
testMatch("docs/{docId}/read", "/docs/mydoc/read", "docId", "mydoc");
testMatch("docs/{docId}/read", "/docs/mydoc/read?myQueryParam", "docId", "mydoc");
// test trailing slashes
testMatch("/docs/mydoc/", "/docs/mydoc/");
testMatch("/docs/{docId}/", "/docs/mydoc/", "docId", "mydoc");
testMatch("/docs/{docId}/{op}/", "/docs/mydoc/read/", "docId", "mydoc", "op", "read");
testMatch("/docs/{docId}/{op}/{allowed}/", "/docs/mydoc/read/true/", "docId", "mydoc", "op", "read", "allowed",
"true");
testMatch("/docs/{docId}/operation/{op}/", "/docs/mydoc/operation/read/", "docId", "mydoc", "op", "read");
testMatch("/docs/{docId}/read/", "/docs/mydoc/read/", "docId", "mydoc");
// test straight replacement of template
testMatch("/{foo}", "/bob", "foo", "bob");
testMatch("{foo}", "/bob", "foo", "bob");
testMatch("/{foo}/", "/bob/", "foo", "bob");
// test that brackets (and the possibility of recursive templates) don't mess up the matching
testMatch("/{value}", "/{value}", "value", "{value}");
}
@Test
public void wildCardTests() {
// wildcard matches
testMatch("/*", "/docs/mydoc/test", "*", "docs/mydoc/test");
testMatch("/docs/*", "/docs/mydoc/test", "*", "mydoc/test");
testMatch("/docs*", "/docs/mydoc/test", "*", "/mydoc/test");
testMatch("/docs/*", "/docs/mydoc/test/test2", "*", "mydoc/test/test2");
testMatch("/docs/{docId}/*", "/docs/mydoc/test", "docId", "mydoc", "*", "test");
testMatch("/docs/{docId}/*", "/docs/mydoc/", "docId", "mydoc", "*", "");
testMatch("/docs/{docId}/*", "/docs/mydoc/test/test2/test3/test4", "docId", "mydoc", "*",
"test/test2/test3/test4");
testMatch("/docs/{docId}/{docId2}/*", "/docs/mydoc/test/test2/test3/test4", "docId", "mydoc", "docId2", "test",
"*", "test2/test3/test4");
}
@SuppressWarnings("ThrowableResultIgnored")
@Test
public void testNullPath() {
Assert.assertThrows(IllegalArgumentException.class, () -> {
PathTemplateParser.parseTemplate(null, new Object());
});
}
@Test
public void testDetectDuplicates() {
final HashSet<PathTemplateParser.PathTemplatePatternEqualsAdapter<PathTemplateParser.PathTemplate<Object>>> seen = new HashSet<>();
seen.add(new PathTemplateParser.PathTemplatePatternEqualsAdapter<>(
PathTemplateParser.parseTemplate("/bob/{foo}", new Object())
));
Assert.assertTrue(seen.contains(new PathTemplateParser.PathTemplatePatternEqualsAdapter<>(
PathTemplateParser.parseTemplate("/bob/{ak}", new Object())
)));
Assert.assertFalse(seen.contains(new PathTemplateParser.PathTemplatePatternEqualsAdapter<>(
PathTemplateParser.parseTemplate("/bob/{ak}/other", new Object())
)));
}
@Test
public void testTrailingSlash() {
final String t1 = "target-1";
PathTemplateRouter<String> router = PathTemplateRouterFactory.Builder.newBuilder()
.updateDefaultTarget(defaultTarget)
.addTemplate("/bob/", () -> t1)
.build();
Assert.assertNotSame(t1, router.route("/bob").getTarget());
Assert.assertSame(t1, router.route("/bob/").getTarget());
router = PathTemplateRouterFactory.Builder.newBuilder()
.updateDefaultTarget(defaultTarget)
.addTemplate("/bob/{id}/", () -> t1)
.build();
Assert.assertNotSame(t1, router.route("/bob/1").getTarget());
Assert.assertSame(t1, router.route("/bob/1/").getTarget());
}
private void testMatch(final String template, final String path, final String... pathParams) {
Assert.assertEquals(0, pathParams.length % 2);
final Map<String, String> expected = new HashMap<>();
for (int i = 0; i < pathParams.length; i += 2) {
expected.put(pathParams[i], pathParams[i + 1]);
}
final String t1 = "target-1";
final PathTemplateRouter<String> router = PathTemplateRouterFactory.Builder.newBuilder()
.updateDefaultTarget(defaultTarget)
.addTemplate(template, () -> t1)
.build();
final PathTemplateRouteResult<String> routeResult = router.route(path);
Assert.assertSame("Failed. Template: " + template, t1, routeResult.getTarget());
Assert.assertEquals(expected, routeResult.getParameters());
}
/* =============================================================================================================
Below are some performance benchmarks that compareMostSpecificToLeastSpecific the performance of the existing path template matcher
with the performance of the new path template router.
The @Test annoation is usually commented out, to prevent these from running during regular builds.
Uncomment to run and see the results.
============================================================================================================= */
private static List<String> createTemplates(
final int segmentCount,
final int n
) {
final List<String> result = new LinkedList<>();
for (int s = 0; s < segmentCount; s++) {
for (int vp = -1; vp <= s; vp++) {
for (int i = 0; i < n; i++) {
final StringBuilder sb = new StringBuilder();
boolean duplicate = false;
for (int j = 0; j <= s; j++) {
if (vp == j) {
if (j > 0 || i == 0) {
sb.append("/{var_").append(i).append("_").append(j).append("}");
} else {
duplicate = true;
}
} else {
sb.append("/path-").append(i).append("-seg-").append(j);
}
}
if (!duplicate) {
result.add(sb.toString());
}
}
}
}
return result;
}
private static List<String> createRequests(
final int segmentCount,
final int n
) {
final List<String> result = new LinkedList<>();
for (int s = 0; s < segmentCount; s++) {
for (int vp = -1; vp <= s; vp++) {
for (int i = 0; i < n; i++) {
final StringBuilder sb = new StringBuilder();
for (int j = 0; j <= s; j++) {
if (vp == j) {
sb.append("/a-value-for-the-var");
} else {
sb.append("/path-").append(i).append("-seg-").append(j);
}
}
result.add(sb.toString());
}
}
}
return result;
}
private static long routeOld(
final int segmentCount,
final int n,
final int requestCount
) {
final List<String> templates = createTemplates(segmentCount, n);
final String[] requests = createRequests(segmentCount, n).toArray(String[]::new);
final int requestsLen = requests.length;
final PathTemplateMatcher<String> matcher = new PathTemplateMatcher<>();
for (final String template : templates) {
matcher.add(PathTemplate.create(template), template);
}
PathTemplateMatcher.PathMatchResult<String> pathMatchResult;
final long startMillis = System.currentTimeMillis();
for (int i = 0; i < requestCount; i++) {
pathMatchResult = matcher.match(requests[i % requestsLen]);
}
final long endMillis = System.currentTimeMillis();
return endMillis - startMillis;
}
private static long routeNew(
final int segmentCount,
final int n,
final int requestCount
) {
final List<String> templates = createTemplates(segmentCount, n);
final String[] requests = createRequests(segmentCount, n).toArray(String[]::new);
final int requestsLen = requests.length;
final PathTemplateRouterFactory.Builder<Supplier<String>, String> routerBuilder = PathTemplateRouterFactory.Builder
.newBuilder()
.updateDefaultTarget(
"default"
);
for (final String template : templates) {
routerBuilder.addTemplate(template, () -> template);
}
final PathTemplateRouter<String> router = routerBuilder.build();
final long startMillis = System.currentTimeMillis();
for (int i = 0; i < requestCount; i++) {
router.route(requests[i % requestsLen]);
}
final long endMillis = System.currentTimeMillis();
return endMillis - startMillis;
}
// @Test
public void comparePerformance() {
final int warmUpSeconds = 10;
final int segmentCount = 7;
final int maxN = 36;
final int requestCount = 20_000_000;
final String resultsFile = "/tmp/path-template-router-performance.txt";
// JVM warm up.
long endWarmup = System.currentTimeMillis() + warmUpSeconds * 1_000L;
while (System.currentTimeMillis() < endWarmup) {
routeOld(7, 1, requestCount);
routeNew(7, 1, requestCount);
}
// Run the performance benchmarks.
final int[][] results = new int[maxN][];
for (int i = 0; i < maxN; i++) {
results[i] = new int[5];
results[i][0] = i + 1;
results[i][1] = createTemplates(segmentCount, i + 1).size();
results[i][2] = requestCount;
results[i][3] = (int) routeOld(segmentCount, i + 1, requestCount);
results[i][4] = (int) routeNew(segmentCount, i + 1, requestCount);
}
// Write the results to a file.
final File file = new File(resultsFile);
if (file.exists()) {
file.delete();
}
try (PrintWriter pw = new PrintWriter(file)) {
pw.println("n,template_count,request_count,old,new");
for (int i = 0; i < maxN; i++) {
pw.println(String.format(
"%d,%d,%d,%d,%d",
results[i][0], results[i][1], results[i][2], results[i][3], results[i][4]
));
}
} catch (final IOException ex) {
ex.printStackTrace();
}
}
}