Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ Changes log
- Added an optional origin matcher to CORS (`CorsService`, `CorsFilter`, `setAllowedOriginMatcher(Predicate<String>)`) 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.
Expand Down
58 changes: 53 additions & 5 deletions org.restlet/src/main/java/org/restlet/routing/Template.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<String> regexVariables;

Expand Down Expand Up @@ -517,6 +534,7 @@ private Pattern getRegexPattern() {
}
}

this.segmentRegexPattern = null;
this.regexPattern = Pattern.compile(patternBuffer.toString());
}
}
Expand All @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand All @@ -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();
}
}
Expand Down Expand Up @@ -676,9 +726,7 @@ public int parse(String formattedString, Map<String, Object> 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
Expand Down
35 changes: 35 additions & 0 deletions org.restlet/src/test/java/org/restlet/routing/RouterTestCase.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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<String, Object> 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"));
}
}
Loading