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..13167a4bc8 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,20 @@ 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; + + /** + * 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 final String SEGMENT_BOUNDARY = "(?:\\A|\\z|(?<=/)|(?=[/?;#]))"; + /** * Appends to a pattern a repeating group of a given content based on a class of characters. * @@ -220,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; @@ -517,6 +534,7 @@ private Pattern getRegexPattern() { } } + this.segmentRegexPattern = null; this.regexPattern = Pattern.compile(patternBuffer.toString()); } } @@ -525,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. * @@ -615,6 +650,22 @@ public boolean isEncodingVariables() { return this.encodingVariables; } + /** + * Applies the matching mode to a matcher. + * + * @param matcher The matcher on the formatted string. + * @return True if the formatted string matches according to the matching mode. + */ + 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.usePattern(getSegmentRegexPattern()).lookingAt(); + default -> false; + }; + } + /** * Indicates if the current pattern matches the given formatted string. * @@ -628,8 +679,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)) { result = matcher.end(); } } @@ -676,9 +726,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); 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..0479849c08 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,54 @@ 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 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")); + } }