Skip to content

Commit 5d46410

Browse files
committed
Support optional path parameter:
- Add Router.expandOptionalVariables - Add extra path parameter to internal router - Tests for script and mvc routes - Update documentation
1 parent f4c917a commit 5d46410

6 files changed

Lines changed: 298 additions & 16 deletions

File tree

docs/asciidoc/routing.adoc

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,38 @@ Any runtime annotation is automatically added as route attributes following thes
260260
<2> Retrieve string variable: `file`
261261
<3> Retrieve string variable: `ext`
262262

263+
.Optional path variable:
264+
[source, java, role="primary"]
265+
----
266+
{
267+
// <1>
268+
get("/profile/{id}?", ctx -> {
269+
String id = ctx.path("id").value("self"); // <2>
270+
return id;
271+
});
272+
}
273+
----
274+
275+
.Kotlin
276+
[source, kotlin, role="secondary"]
277+
----
278+
{
279+
// <1>
280+
get("/profile/{id}?") {
281+
val id = ctx.path("id").value("self") // <2>
282+
id
283+
}
284+
}
285+
----
286+
287+
<1> Defines an optional path variable `id`. The trailing `?` make it optional.
288+
<2> Retrieve the variable `id` as `String` when present or use a default value: `self`.
289+
290+
The trailing `?` makes the path variable optional. The route matches:
291+
292+
- `/profile`
293+
- `/profile/eespina`
294+
263295
==== Regex
264296

265297
.Regex path variable:
@@ -289,6 +321,11 @@ Any runtime annotation is automatically added as route attributes following thes
289321
<1> Defines a path variable: `id`. Regex expression is everything after the first `:`, like: `[0-9]+`
290322
<2> Retrieve an int value
291323

324+
Optional syntax is also supported for regex path variable: `/user/{id:[0-9]+}?`:
325+
326+
- matches `/user`
327+
- matches `/user/123`
328+
292329
==== * Catchall
293330

294331
.catchall

jooby/src/main/java/io/jooby/Router.java

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
import java.util.Map;
2323
import java.util.Set;
2424
import java.util.concurrent.Executor;
25+
import java.util.concurrent.atomic.AtomicInteger;
26+
import java.util.function.BiConsumer;
27+
import java.util.function.Consumer;
2528
import java.util.function.Predicate;
29+
import java.util.regex.Pattern;
30+
import java.util.stream.Collectors;
2631
import java.util.stream.IntStream;
2732
import java.util.stream.Stream;
2833

@@ -881,6 +886,104 @@ default Router error(@Nonnull Predicate<StatusCode> predicate,
881886
}
882887
}
883888

889+
/**
890+
* Look for optional path parameter and expand the given pattern into multiple pattern.
891+
*
892+
* <pre>
893+
* /path => [/path]
894+
* /{id} => [/{id}]
895+
* /path/{id} => [/path/{id}]
896+
*
897+
* /{id}? => [/, /{id}]
898+
* /path/{id}? => [/path, /path/{id}]
899+
* /path/{id}/{start}?/{end}? => [/path/{id}, /path/{id}/{start}, /path/{id}/{start}/{end}]
900+
* /path/{id}?/suffix => [/path, /path/{id}, /path/suffix]
901+
* </pre>
902+
*
903+
* @param pattern Pattern.
904+
* @return One or more patterns.
905+
*/
906+
static @Nonnull List<String> expandOptionalVariables(@Nonnull String pattern) {
907+
if (pattern == null || pattern.isEmpty() || pattern.equals("/")) {
908+
return Collections.singletonList("/");
909+
}
910+
int len = pattern.length();
911+
AtomicInteger key = new AtomicInteger();
912+
Map<Integer, StringBuilder> paths = new HashMap<>();
913+
BiConsumer<Integer, StringBuilder> pathAppender = (index, segment) -> {
914+
for (int i = index; i < index - 1; i++) {
915+
paths.get(i).append(segment);
916+
}
917+
paths.computeIfAbsent(index, current -> {
918+
StringBuilder value = new StringBuilder();
919+
if (current > 0) {
920+
StringBuilder previous = paths.get(current - 1);
921+
if (!previous.toString().equals("/")) {
922+
value.append(previous);
923+
}
924+
}
925+
return value;
926+
}).append(segment);
927+
};
928+
StringBuilder segment = new StringBuilder();
929+
boolean isLastOptional = false;
930+
for (int i = 0; i < len; ) {
931+
char ch = pattern.charAt(i);
932+
if (ch == '/') {
933+
if (segment.length() > 0) {
934+
pathAppender.accept(key.get(), segment);
935+
segment.setLength(0);
936+
}
937+
segment.append(ch);
938+
i += 1;
939+
} else if (ch == '{') {
940+
segment.append(ch);
941+
int curly = 1;
942+
int j = i + 1;
943+
while (j < len) {
944+
char next = pattern.charAt(j++);
945+
segment.append(next);
946+
if (next == '{') {
947+
curly += 1;
948+
} else if (next == '}') {
949+
curly -= 1;
950+
if (curly == 0) {
951+
break;
952+
}
953+
}
954+
}
955+
if (j < len && pattern.charAt(j) == '?') {
956+
j += 1;
957+
isLastOptional = true;
958+
if (paths.isEmpty()) {
959+
paths.put(0, new StringBuilder("/"));
960+
}
961+
pathAppender.accept(key.incrementAndGet(), segment);
962+
} else {
963+
isLastOptional = false;
964+
pathAppender.accept(key.get(), segment);
965+
}
966+
segment.setLength(0);
967+
i = j;
968+
} else {
969+
segment.append(ch);
970+
i += 1;
971+
}
972+
}
973+
if (paths.isEmpty()) {
974+
return Collections.singletonList(pattern);
975+
}
976+
if (segment.length() > 0) {
977+
pathAppender.accept(key.get(), segment);
978+
if (isLastOptional) {
979+
paths.put(key.incrementAndGet(), segment);
980+
}
981+
}
982+
return paths.values().stream()
983+
.map(StringBuilder::toString)
984+
.collect(Collectors.toList());
985+
}
986+
884987
/**
885988
* Recreate a path pattern using the given variables. Variable replacement is done using the
886989
* current index.

jooby/src/main/java/io/jooby/internal/RouterImpl.java

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -448,21 +448,23 @@ private Route newRoute(@Nonnull String method, @Nonnull String pattern,
448448
finalPattern = finalPattern.toLowerCase();
449449
}
450450

451-
if (route.getMethod().equals(WS)) {
452-
tree.insert(GET, finalPattern, route);
453-
route.setReturnType(Context.class);
454-
} else if (route.getMethod().equals(SSE)) {
455-
tree.insert(GET, finalPattern, route);
456-
route.setReturnType(Context.class);
457-
} else {
458-
tree.insert(route.getMethod(), finalPattern, route);
459-
460-
if (route.isHttpOptions()) {
461-
tree.insert(Router.OPTIONS, finalPattern, route);
462-
} else if (route.isHttpTrace()) {
463-
tree.insert(Router.TRACE, finalPattern, route);
464-
} else if (route.isHttpHead() && route.getMethod().equals(GET)) {
465-
tree.insert(Router.HEAD, finalPattern, route);
451+
for (String routePattern : Router.expandOptionalVariables(finalPattern)) {
452+
if (route.getMethod().equals(WS)) {
453+
tree.insert(GET, routePattern, route);
454+
route.setReturnType(Context.class);
455+
} else if (route.getMethod().equals(SSE)) {
456+
tree.insert(GET, routePattern, route);
457+
route.setReturnType(Context.class);
458+
} else {
459+
tree.insert(route.getMethod(), routePattern, route);
460+
461+
if (route.isHttpOptions()) {
462+
tree.insert(Router.OPTIONS, routePattern, route);
463+
} else if (route.isHttpTrace()) {
464+
tree.insert(Router.TRACE, routePattern, route);
465+
} else if (route.isHttpHead() && route.getMethod().equals(GET)) {
466+
tree.insert(Router.HEAD, routePattern, route);
467+
}
466468
}
467469
}
468470
routes.add(route);

jooby/src/test/java/io/jooby/RouterTest.java

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import org.junit.jupiter.api.Test;
44

5+
import java.util.Arrays;
56
import java.util.Collections;
67
import java.util.HashMap;
78
import java.util.List;
@@ -19,6 +20,14 @@ public void pathKeys() {
1920
assertEquals(Collections.singletonList("lang"), keys);
2021
});
2122

23+
pathKeys("/edit/{id}?", keys -> {
24+
assertEquals(Collections.singletonList("id"), keys);
25+
});
26+
27+
pathKeys("/path/{id}/{start}?/{end}?", keys -> {
28+
assertEquals(Arrays.asList("id", "start", "end"), keys);
29+
});
30+
2231
pathKeys("/*", keys -> assertEquals(1, keys.size()));
2332

2433
pathKeys("/foo/?*", keys -> assertEquals(1, keys.size()));
@@ -48,7 +57,8 @@ public void reverse() {
4857

4958
assertEquals("/123", Router.reverse("/{regex:\\d+}", map("regex", 123)));
5059

51-
assertEquals("/resources/123/edit", Router.reverse("/resources/{num:\\d+}/edit", map("num", 123)));
60+
assertEquals("/resources/123/edit",
61+
Router.reverse("/resources/{num:\\d+}/edit", map("num", 123)));
5262

5363
assertEquals("/prefix/v1/v2", Router.reverse("/prefix/{k1}/{k2}", map("k1", "v1", "k2", "v2")));
5464

@@ -61,13 +71,95 @@ public void reverse() {
6171
assertEquals("/", Router.reverse("/", Collections.emptyMap()));
6272
assertEquals("/path", Router.reverse("/path", Collections.emptyMap()));
6373
assertEquals("/path", Router.reverse("/path", map("k", "v")));
74+
}
75+
76+
@Test
77+
public void shouldExpandOptionalParams() {
78+
parse("/{lang:[a-z]{2}}?", paths -> {
79+
assertEquals(2, paths.size());
80+
assertEquals("/", paths.get(0));
81+
assertEquals("/{lang:[a-z]{2}}", paths.get(1));
82+
});
83+
parse("/{lang:[a-z]{2}}", paths -> {
84+
assertEquals(1, paths.size());
85+
assertEquals("/{lang:[a-z]{2}}", paths.get(0));
86+
});
87+
parse("/edit/{id:[0-9]+}?", paths -> {
88+
assertEquals(2, paths.size());
89+
assertEquals("/edit", paths.get(0));
90+
assertEquals("/edit/{id:[0-9]+}", paths.get(1));
91+
});
92+
parse("/path/{id}/{start}?/{end}?", paths -> {
93+
assertEquals(3, paths.size());
94+
assertEquals("/path/{id}", paths.get(0));
95+
assertEquals("/path/{id}/{start}", paths.get(1));
96+
assertEquals("/path/{id}/{start}/{end}", paths.get(2));
97+
});
98+
parse("/{id}?/suffix", paths -> {
99+
assertEquals(3, paths.size());
100+
assertEquals("/", paths.get(0));
101+
assertEquals("/{id}/suffix", paths.get(1));
102+
assertEquals("/suffix", paths.get(2));
103+
});
104+
parse("/prefix/{id}?", paths -> {
105+
assertEquals(2, paths.size());
106+
assertEquals("/prefix", paths.get(0));
107+
assertEquals("/prefix/{id}", paths.get(1));
108+
});
109+
parse("/{id}?", paths -> {
110+
assertEquals(2, paths.size());
111+
assertEquals("/", paths.get(0));
112+
assertEquals("/{id}", paths.get(1));
113+
});
114+
parse("/path", paths -> {
115+
assertEquals(1, paths.size());
116+
assertEquals("/path", paths.get(0));
117+
});
64118

119+
parse("/path/subpath", paths -> {
120+
assertEquals(1, paths.size());
121+
assertEquals("/path/subpath", paths.get(0));
122+
});
123+
124+
parse("/{id}", paths -> {
125+
assertEquals(1, paths.size());
126+
assertEquals("/{id}", paths.get(0));
127+
});
128+
129+
parse("/{id}/suffix", paths -> {
130+
assertEquals(1, paths.size());
131+
assertEquals("/{id}/suffix", paths.get(0));
132+
});
133+
134+
parse("/prefix/{id}", paths -> {
135+
assertEquals(1, paths.size());
136+
assertEquals("/prefix/{id}", paths.get(0));
137+
});
138+
139+
parse("/", paths -> {
140+
assertEquals(1, paths.size());
141+
assertEquals("/", paths.get(0));
142+
});
143+
144+
parse(null, paths -> {
145+
assertEquals(1, paths.size());
146+
assertEquals("/", paths.get(0));
147+
});
148+
149+
parse("", paths -> {
150+
assertEquals(1, paths.size());
151+
assertEquals("/", paths.get(0));
152+
});
65153
}
66154

67155
private void pathKeys(String pattern, Consumer<List<String>> consumer) {
68156
consumer.accept(Router.pathKeys(pattern));
69157
}
70158

159+
private void parse(String pattern, Consumer<List<String>> consumer) {
160+
consumer.accept(Router.expandOptionalVariables(pattern));
161+
}
162+
71163
public Map<String, Object> map(Object... values) {
72164
Map<String, Object> map = new HashMap<>();
73165
for (int i = 0; i < values.length; i += 2) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.jooby;
2+
3+
import io.jooby.i1573.Controller1573;
4+
import io.jooby.junit.ServerTest;
5+
import io.jooby.junit.ServerTestRunner;
6+
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
9+
public class Issue1573 {
10+
11+
@ServerTest
12+
public void issue1573(ServerTestRunner runner) {
13+
runner.define(app -> {
14+
app.get("/edit/{id:[0-9]+}?", ctx -> ctx.path("id").value("own"));
15+
app.mvc(new Controller1573());
16+
}).ready(client -> {
17+
client.get("/edit", rsp -> {
18+
assertEquals("own", rsp.body().string());
19+
});
20+
21+
client.get("/edit/123", rsp -> {
22+
assertEquals("123", rsp.body().string());
23+
});
24+
25+
client.get("/profile", rsp -> {
26+
assertEquals("self", rsp.body().string());
27+
});
28+
29+
client.get("/profile/123", rsp -> {
30+
assertEquals("123", rsp.body().string());
31+
});
32+
});
33+
}
34+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.jooby.i1573;
2+
3+
import io.jooby.annotations.GET;
4+
import io.jooby.annotations.PathParam;
5+
6+
import java.util.Optional;
7+
8+
public class Controller1573 {
9+
10+
@GET("/profile/{id}?")
11+
public String profile(@PathParam Optional<String> id) {
12+
return id.orElse("self");
13+
}
14+
}

0 commit comments

Comments
 (0)