From e80e1ad0d649e595c0c73f5df70ff05b9bee96d2 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:11:59 -0400 Subject: [PATCH 1/2] feat: add Template.MODE_STARTS_WITH_SEGMENT matching mode Like MODE_STARTS_WITH, but the match must end on a URI path segment boundary (end of string, '/', '?', ';' or '#', or a match ending with '/'). A route attached to /mcp then matches /mcp, /mcp/tools and /mcp?x=1 but not /mcpx, which makes it safe to mount several routers or applications under path prefixes on one connector. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- changes.md | 4 ++ .../java/org/restlet/routing/Template.java | 49 +++++++++++++++++-- .../org/restlet/routing/RouterTestCase.java | 35 +++++++++++++ .../org/restlet/routing/TemplateTestCase.java | 37 ++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/changes.md b/changes.md index 4106e5e0a2..6c285364db 100644 --- a/changes.md +++ b/changes.md @@ -145,6 +145,10 @@ Changes log - Added an optional origin matcher to CORS (`CorsService`, `CorsFilter`, `setAllowedOriginMatcher(Predicate)`) to accept origins that are not listed, for example any sub-domain. The response always contains the exact request origin. Issue #1611. - Added the `SameSite` attribute to cookie settings (`CookieSetting.get/setSameSite()`, `SameSite` enum), read and written in `Set-Cookie`. + - Added the `Template.MODE_STARTS_WITH_SEGMENT` matching mode: like `MODE_STARTS_WITH`, but + the match must end on a URI path segment boundary, so a route attached to "/mcp" matches + "/mcp", "/mcp/tools" and "/mcp?x=1" but not "/mcpx". Useful for mounting several + routers or applications under path prefixes on one connector. - Bugs fixed - Fixed the OpenAPI extension build path for Commons Lang types inherited from Swagger. - Slf4j bridge reports wrong line number and caller class in log4j. Issue #233. diff --git a/org.restlet/src/main/java/org/restlet/routing/Template.java b/org.restlet/src/main/java/org/restlet/routing/Template.java index a8c73fba0c..8036820bd2 100644 --- a/org.restlet/src/main/java/org/restlet/routing/Template.java +++ b/org.restlet/src/main/java/org/restlet/routing/Template.java @@ -47,6 +47,31 @@ public class Template { /** Mode where characters at the beginning must match the template. */ public static final int MODE_STARTS_WITH = 1; + /** + * Mode where characters at the beginning must match the template, and the match must end on a + * URI path segment boundary: the end of the string, or a '/', '?', ';' or '#' character, or a + * match that itself ends with '/'. For example, "/mcp" matches "/mcp", "/mcp/tools" and + * "/mcp?x=1" but not "/mcpx". An empty template matches any string. + */ + public static final int MODE_STARTS_WITH_SEGMENT = 3; + + /** + * Indicates whether a matched length ends on a URI path segment boundary. + * + * @param formattedString The string being matched. + * @param end The number of matched characters. + * @return True if the match ends on a segment boundary. + */ + private static boolean isSegmentBoundary(String formattedString, int end) { + if (end == 0 || end >= formattedString.length()) { + return true; + } + + char previous = formattedString.charAt(end - 1); + char next = formattedString.charAt(end); + return previous == '/' || next == '/' || next == '?' || next == ';' || next == '#'; + } + /** * Appends to a pattern a repeating group of a given content based on a class of characters. * @@ -615,6 +640,23 @@ public boolean isEncodingVariables() { return this.encodingVariables; } + /** + * Applies the matching mode to a matcher. + * + * @param matcher The matcher on the formatted string. + * @param formattedString The formatted string. + * @return True if the formatted string matches according to the matching mode. + */ + private boolean matches(Matcher matcher, String formattedString) { + return switch (getMatchingMode()) { + case MODE_EQUALS -> matcher.matches(); + case MODE_STARTS_WITH -> matcher.lookingAt(); + case MODE_STARTS_WITH_SEGMENT -> + matcher.lookingAt() && isSegmentBoundary(formattedString, matcher.end()); + default -> false; + }; + } + /** * Indicates if the current pattern matches the given formatted string. * @@ -628,8 +670,7 @@ public int match(String formattedString) { if (formattedString != null) { final Matcher matcher = getRegexPattern().matcher(formattedString); - if (((getMatchingMode() == MODE_EQUALS) && matcher.matches()) - || ((getMatchingMode() == MODE_STARTS_WITH) && matcher.lookingAt())) { + if (matches(matcher, formattedString)) { result = matcher.end(); } } @@ -676,9 +717,7 @@ public int parse(String formattedString, Map variables, boolean if (formattedString != null) { try { Matcher matcher = getRegexPattern().matcher(formattedString); - boolean matched = - ((getMatchingMode() == MODE_EQUALS) && matcher.matches()) - || ((getMatchingMode() == MODE_STARTS_WITH) && matcher.lookingAt()); + boolean matched = matches(matcher, formattedString); if (matched) { // Update the number of matched characters diff --git a/org.restlet/src/test/java/org/restlet/routing/RouterTestCase.java b/org.restlet/src/test/java/org/restlet/routing/RouterTestCase.java index 58127e7c24..a1421c8329 100644 --- a/org.restlet/src/test/java/org/restlet/routing/RouterTestCase.java +++ b/org.restlet/src/test/java/org/restlet/routing/RouterTestCase.java @@ -363,4 +363,39 @@ void settersRoundTrip() { router.setRoutes(routes); assertSame(routes, router.getRoutes()); } + + @Test + void segmentMatchingMode_routesPrefixesOnSegmentBoundaries() { + Router router = new Router(new Context()); + router.attach("/mcp", namedRouter("mcp"), Template.MODE_STARTS_WITH_SEGMENT); + router.attach("/mcp-admin", namedRouter("admin"), Template.MODE_STARTS_WITH_SEGMENT); + router.attach("", namedRouter("root"), Template.MODE_STARTS_WITH_SEGMENT); + + assertEquals("mcp:", route(router, "/mcp")); + assertEquals("mcp:/tools", route(router, "/mcp/tools")); + assertEquals("admin:/tools", route(router, "/mcp-admin/tools")); + assertEquals("root:/mcpx", route(router, "/mcpx")); + } + + private static Router namedRouter(String name) { + Router router = new Router(); + router.attachDefault( + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setEntity( + name + ":" + request.getResourceRef().getRemainingPart(), + org.restlet.data.MediaType.TEXT_PLAIN); + } + }); + return router; + } + + private static String route(Router router, String path) { + Request request = new Request(Method.GET, "http://localhost" + path); + request.getResourceRef().setBaseRef(new Reference("http://localhost")); + Response response = new Response(request); + router.handle(request, response); + return response.getEntityAsText(); + } } diff --git a/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java b/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java index af45fa0e73..6138a0309c 100644 --- a/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java +++ b/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java @@ -89,4 +89,41 @@ void testWithPercentChars() { assertTrue(parsed2 >= 0); assertEquals("hf%20kh", variables2.get("v1")); } + + @Test + void testStartsWithSegmentMatchesOnSegmentBoundaries() { + Template template = new Template("/mcp", Template.MODE_STARTS_WITH_SEGMENT); + + assertEquals(4, template.match("/mcp")); + assertEquals(4, template.match("/mcp/")); + assertEquals(4, template.match("/mcp/tools")); + assertEquals(4, template.match("/mcp?x=1")); + assertEquals(4, template.match("/mcp;v=1")); + assertEquals(4, template.match("/mcp#top")); + assertEquals(-1, template.match("/mcpx")); + assertEquals(-1, template.match("/mcp-admin/tools")); + assertEquals(-1, template.match("/other")); + } + + @Test + void testStartsWithSegmentAcceptsTrailingSlashAndEmptyTemplates() { + assertEquals(5, new Template("/mcp/", Template.MODE_STARTS_WITH_SEGMENT).match("/mcp/x")); + assertEquals(0, new Template("", Template.MODE_STARTS_WITH_SEGMENT).match("/anything")); + } + + @Test + void testStartsWithSegmentParsesVariables() { + Template template = new Template("/tenants/{id}", Template.MODE_STARTS_WITH_SEGMENT); + template.getVariables().put("id", new Variable(Variable.TYPE_URI_SEGMENT)); + + Map variables = new HashMap<>(); + assertEquals(10, template.parse("/tenants/a/orders", variables)); + assertEquals("a", variables.get("id")); + assertEquals(-1, template.parse("/tenantsx/a", new HashMap<>())); + } + + @Test + void testStartsWithKeepsCharacterMatching() { + assertEquals(4, new Template("/mcp", Template.MODE_STARTS_WITH).match("/mcpx")); + } } From d48442375ca880b903fba265610ed74cab54c872 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:37:34 -0400 Subject: [PATCH 2/2] fix: let segment matching backtrack to an earlier boundary Check the segment boundary inside the regex instead of after lookingAt(), so a greedy variable can backtrack to an earlier valid boundary, as in "/files/{name}.json" matching "/files/a.json;b.jsonx". Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../java/org/restlet/routing/Template.java | 47 +++++++++++-------- .../org/restlet/routing/TemplateTestCase.java | 13 +++++ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/org.restlet/src/main/java/org/restlet/routing/Template.java b/org.restlet/src/main/java/org/restlet/routing/Template.java index 8036820bd2..13167a4bc8 100644 --- a/org.restlet/src/main/java/org/restlet/routing/Template.java +++ b/org.restlet/src/main/java/org/restlet/routing/Template.java @@ -56,21 +56,10 @@ public class Template { public static final int MODE_STARTS_WITH_SEGMENT = 3; /** - * Indicates whether a matched length ends on a URI path segment boundary. - * - * @param formattedString The string being matched. - * @param end The number of matched characters. - * @return True if the match ends on a segment boundary. + * Regex suffix requiring a match to end on a URI path segment boundary. Being part of the + * pattern lets the regex backtrack to an earlier boundary. */ - private static boolean isSegmentBoundary(String formattedString, int end) { - if (end == 0 || end >= formattedString.length()) { - return true; - } - - char previous = formattedString.charAt(end - 1); - char next = formattedString.charAt(end); - return previous == '/' || next == '/' || next == '?' || next == ';' || next == '#'; - } + private static final String SEGMENT_BOUNDARY = "(?:\\A|\\z|(?<=/)|(?=[/?;#]))"; /** * Appends to a pattern a repeating group of a given content based on a class of characters. @@ -245,6 +234,9 @@ private static String getVariableRegex(Variable variable) { /** The internal Regex pattern. */ private volatile Pattern regexPattern; + /** The internal Regex pattern requiring a match to end on a segment boundary. */ + private volatile Pattern segmentRegexPattern; + /** The sequence of Regex variable names as found in the pattern string. */ private volatile List regexVariables; @@ -542,6 +534,7 @@ private Pattern getRegexPattern() { } } + this.segmentRegexPattern = null; this.regexPattern = Pattern.compile(patternBuffer.toString()); } } @@ -550,6 +543,23 @@ private Pattern getRegexPattern() { return this.regexPattern; } + /** + * Returns the Regex pattern requiring a match to end on a URI path segment boundary. + * + * @return The Regex pattern requiring a match to end on a segment boundary. + */ + private Pattern getSegmentRegexPattern() { + Pattern base = getRegexPattern(); + Pattern result = this.segmentRegexPattern; + + if (result == null) { + result = Pattern.compile(base.pattern() + SEGMENT_BOUNDARY); + this.segmentRegexPattern = result; + } + + return result; + } + /** * Returns the sequence of Regex variable names as found in the pattern string. * @@ -644,15 +654,14 @@ public boolean isEncodingVariables() { * Applies the matching mode to a matcher. * * @param matcher The matcher on the formatted string. - * @param formattedString The formatted string. * @return True if the formatted string matches according to the matching mode. */ - private boolean matches(Matcher matcher, String formattedString) { + private boolean matches(Matcher matcher) { return switch (getMatchingMode()) { case MODE_EQUALS -> matcher.matches(); case MODE_STARTS_WITH -> matcher.lookingAt(); case MODE_STARTS_WITH_SEGMENT -> - matcher.lookingAt() && isSegmentBoundary(formattedString, matcher.end()); + matcher.usePattern(getSegmentRegexPattern()).lookingAt(); default -> false; }; } @@ -670,7 +679,7 @@ public int match(String formattedString) { if (formattedString != null) { final Matcher matcher = getRegexPattern().matcher(formattedString); - if (matches(matcher, formattedString)) { + if (matches(matcher)) { result = matcher.end(); } } @@ -717,7 +726,7 @@ public int parse(String formattedString, Map variables, boolean if (formattedString != null) { try { Matcher matcher = getRegexPattern().matcher(formattedString); - boolean matched = matches(matcher, formattedString); + boolean matched = matches(matcher); if (matched) { // Update the number of matched characters diff --git a/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java b/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java index 6138a0309c..0479849c08 100644 --- a/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java +++ b/org.restlet/src/test/java/org/restlet/routing/TemplateTestCase.java @@ -122,6 +122,19 @@ void testStartsWithSegmentParsesVariables() { assertEquals(-1, template.parse("/tenantsx/a", new HashMap<>())); } + @Test + void testStartsWithSegmentBacktracksToAnEarlierBoundary() { + Template template = new Template("/files/{name}.json", Template.MODE_STARTS_WITH_SEGMENT); + template.getVariables().put("name", new Variable(Variable.TYPE_URI_SEGMENT)); + String uri = "/files/a.json;b.jsonx/tools"; + + assertEquals(13, template.match(uri)); + Map variables = new HashMap<>(); + assertEquals(13, template.parse(uri, variables)); + assertEquals("a", variables.get("name")); + assertEquals(-1, template.match("/files/a.jsonx")); + } + @Test void testStartsWithKeepsCharacterMatching() { assertEquals(4, new Template("/mcp", Template.MODE_STARTS_WITH).match("/mcpx"));