Skip to content

Commit 2f51425

Browse files
blockvoteruibritopt
authored andcommitted
Add greedy path variables to UriTemplate
Also see AWS documentation for greedy path variables: https://aws.amazon.com/blogs/aws/api-gateway-update-new-features-simplify-api-development/
1 parent 2ca4299 commit 2f51425

File tree

4 files changed

+111
-36
lines changed

4 files changed

+111
-36
lines changed

router/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies {
1212
compile("com.google.guava:guava:23.0")
1313

1414
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.4.0")
15+
testImplementation("org.junit.jupiter:junit-jupiter-params:5.4.0")
1516
testImplementation("com.willowtreeapps.assertk:assertk-jvm:0.12")
1617
testImplementation("org.assertj:assertj-core:3.11.1")
1718
testImplementation("io.mockk:mockk:1.8.13.kotlin13")

router/src/main/kotlin/io/moia/router/UriTemplate.kt

+18-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.moia.router
22

3+
import java.lang.IllegalArgumentException
34
import java.net.URLDecoder
45
import java.util.regex.Pattern
56

@@ -9,16 +10,29 @@ class UriTemplate private constructor(private val template: String) {
910
private val parameterNames: List<String>
1011

1112
init {
12-
matches = URI_TEMPLATE_FORMAT.findAll(template)
13+
if (INVALID_GREEDY_PATH_VARIABLE_REGEX.matches(template)) {
14+
throw IllegalArgumentException("Greedy path variables (e.g. '{proxy+}' are only allowed at the end of the template")
15+
}
16+
matches = PATH_VARIABLE_REGEX.findAll(template)
1317
parameterNames = matches.map { it.groupValues[1] }.toList()
1418
templateRegex = template.replace(
15-
URI_TEMPLATE_FORMAT,
19+
PATH_VARIABLE_REGEX,
1620
{ notMatched -> Pattern.quote(notMatched) },
17-
{ matched -> if (matched.groupValues[2].isBlank()) "([^/]+)" else "(${matched.groupValues[2]})" }).toRegex()
21+
{ matched ->
22+
// check for greedy path variables, e.g. '{proxy+}'
23+
if (matched.groupValues[1].endsWith("+")) {
24+
return@replace "(.+)"
25+
}
26+
if (matched.groupValues[2].isBlank()) "([^/]+)" else "(${matched.groupValues[2]})"
27+
}
28+
).toRegex()
1829
}
1930

2031
companion object {
21-
private val URI_TEMPLATE_FORMAT = "\\{([^}]+?)(?::([^}]+))?}".toRegex()
32+
private val PATH_VARIABLE_REGEX = "\\{([^}]+?)(?::([^}]+))?}".toRegex()
33+
private val INVALID_GREEDY_PATH_VARIABLE_REGEX = ".*\\{([^}]+?)(?::([^}]+))?\\+}.+".toRegex()
34+
35+
// Removes query params
2236
fun from(template: String) = UriTemplate(template.split('?')[0].trimSlashes())
2337

2438
fun String.trimSlashes() = "^(/)?(.*?)(/)?$".toRegex().replace(this) { result -> result.groupValues[2] }

router/src/test/kotlin/io/moia/router/RouterTest.kt

+16
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import assertk.assertions.hasSize
66
import assertk.assertions.isEmpty
77
import assertk.assertions.isEqualTo
88
import io.moia.router.Router.Companion.router
9+
import org.junit.jupiter.api.Assertions.assertTrue
910
import org.junit.jupiter.api.Test
1011

1112
class RouterTest {
@@ -26,4 +27,19 @@ class RouterTest {
2627
assert(produces).containsAll("application/json", "application/x-protobuf")
2728
}
2829
}
30+
31+
@Test
32+
fun `should handle greedy path variables successfully`() {
33+
val router = router {
34+
POST("/some/{proxy+}") { r: Request<Unit> ->
35+
ResponseEntity.ok("""{"hello": "world", "request":"${r.body}"}""")
36+
}
37+
}
38+
assert(router.routes).hasSize(1)
39+
with(router.routes.first().requestPredicate) {
40+
assert(method).isEqualTo("POST")
41+
assertTrue(UriTemplate.from(pathPattern).matches("/some/sub/sub/sub/path"))
42+
assert(produces).containsAll("application/json", "application/x-protobuf")
43+
}
44+
}
2945
}
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,93 @@
11
package io.moia.router
22

33
import org.assertj.core.api.BDDAssertions.then
4-
import org.junit.jupiter.api.Test
4+
import org.junit.jupiter.api.assertThrows
5+
import org.junit.jupiter.params.ParameterizedTest
6+
import org.junit.jupiter.params.provider.Arguments
7+
import org.junit.jupiter.params.provider.MethodSource
58
import java.util.UUID
69

710
class UriTemplateTest {
811

9-
@Test
10-
fun `should match without parameter`() {
11-
then(UriTemplate.from("/some").matches("/some")).isTrue()
12+
@ParameterizedTest
13+
@MethodSource("matchTestParams")
14+
fun `match template`(uriTemplate: String, matchTemplate: String, expectedResult: Boolean) {
15+
then(UriTemplate.from(uriTemplate).matches(matchTemplate)).isEqualTo(expectedResult)
1216
}
1317

14-
@Test
15-
fun `should not match simple`() {
16-
then(UriTemplate.from("/some").matches("/some-other")).isFalse()
18+
@ParameterizedTest
19+
@MethodSource("extractTestParams")
20+
fun `extract template`(uriTemplate: String, extractTemplate: String, expectedResult: Map<String, String>) {
21+
then(UriTemplate.from(uriTemplate).extract(extractTemplate)).isEqualTo(expectedResult)
1722
}
1823

19-
@Test
20-
fun `should match with parameter`() {
21-
then(UriTemplate.from("/some/{id}").matches("/some/${UUID.randomUUID()}")).isTrue()
22-
then(UriTemplate.from("/some/{id}/other").matches("/some/${UUID.randomUUID()}/other")).isTrue()
24+
@ParameterizedTest
25+
@MethodSource("notAllowedGreedyPathTemplates")
26+
fun `should throw exception for greedy path variables at the wrong place`(testedValue: String) {
27+
assertThrows<IllegalArgumentException>("Greedy path variables (e.g. '{proxy+}' are only allowed at the end of the template") {
28+
UriTemplate.from(testedValue)
29+
}
2330
}
2431

25-
@Test
26-
fun `should not match with parameter`() {
27-
then(UriTemplate.from("/some/{id}").matches("/some-other/${UUID.randomUUID()}")).isFalse()
28-
then(UriTemplate.from("/some/{id}/other").matches("/some/${UUID.randomUUID()}/other-test")).isFalse()
29-
}
30-
31-
@Test
32-
fun `should extract parameters`() {
33-
then(UriTemplate.from("/some/{first}/other/{second}").extract("/some/first-value/other/second-value"))
34-
.isEqualTo(mapOf("first" to "first-value", "second" to "second-value"))
35-
then(UriTemplate.from("/some").extract("/some")).isEmpty()
36-
}
32+
companion object {
33+
@JvmStatic
34+
@Suppress("unused")
35+
fun matchTestParams() = listOf(
36+
Arguments.of("/some", "/some", true, "should match without parameter"),
37+
Arguments.of("/some", "/some-other", false, "should not match simple"),
38+
Arguments.of("/some/{id}", "/some/${UUID.randomUUID()}", true, "should match with parameter-1"),
39+
Arguments.of("/some/{id}/other", "/some/${UUID.randomUUID()}/other", true, "should match with parameter-2"),
40+
Arguments.of("/some/{id}", "/some-other/${UUID.randomUUID()}", false, "should not match with parameter-1"),
41+
Arguments.of(
42+
"/some/{id}/other",
43+
"/some/${UUID.randomUUID()}/other-test",
44+
false,
45+
"should not match with parameter-2"
46+
),
47+
Arguments.of("/some?a=1", "/some", true, "should match with query parameter 1"),
48+
Arguments.of("/some?a=1&b=2", "/some", true, "should match with query parameter 2"),
49+
Arguments.of(
50+
"/some/{id}?a=1",
51+
"/some/${UUID.randomUUID()}",
52+
true,
53+
"should match with path parameter and query parameter 1"
54+
),
55+
Arguments.of(
56+
"/some/{id}/other?a=1&b=2",
57+
"/some/${UUID.randomUUID()}/other",
58+
true,
59+
"should match with path parameter and query parameter 2"
60+
),
61+
Arguments.of(
62+
"/some/{proxy+}",
63+
"/some/sub/sub/sub/path",
64+
true,
65+
"should handle greedy path variables successfully"
66+
)
67+
)
3768

38-
@Test
39-
fun `should match with query parameter`() {
40-
then(UriTemplate.from("/some?a=1").matches("/some")).isTrue()
41-
then(UriTemplate.from("/some?a=1&b=2").matches("/some")).isTrue()
42-
}
69+
@JvmStatic
70+
@Suppress("unused")
71+
fun extractTestParams() = listOf(
72+
Arguments.of("/some", "/some", emptyMap<String, String>(), "should extract parameters-1"),
73+
Arguments.of(
74+
"/some/{first}/other/{second}",
75+
"/some/first-value/other/second-value",
76+
mapOf("first" to "first-value", "second" to "second-value"),
77+
"should extract parameters 2"
78+
)
79+
)
4380

44-
@Test
45-
fun `should match with path parameter and query parameter`() {
46-
then(UriTemplate.from("/some/{id}?a=1").matches("/some/${UUID.randomUUID()}")).isTrue()
47-
then(UriTemplate.from("/some/{id}/other?a=1&b=2").matches("/some/${UUID.randomUUID()}/other")).isTrue()
81+
@JvmStatic
82+
@Suppress("unused")
83+
fun notAllowedGreedyPathTemplates() = listOf(
84+
"/some/{proxy+}/and/{variable}/error",
85+
"/{proxy+}/some/and/{variable}/error",
86+
"/here/some/and/{proxy+}/{variable}",
87+
"/here/some/and/{proxy+}/error", // FIXME: it should throw exception
88+
"/here/some/and//good/good/{proxy+}/bad/bad/bad", // FIXME: it should throw exception
89+
"/{proxy+}/{id}",
90+
"/{proxy+}/whatever"
91+
)
4892
}
4993
}

0 commit comments

Comments
 (0)