Skip to content

Commit d4890b0

Browse files
committed
Add URITemplate and use it to match request paths
1 parent 2d043b2 commit d4890b0

File tree

7 files changed

+165
-18
lines changed

7 files changed

+165
-18
lines changed

build.gradle.kts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
12
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
2-
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
33
import com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer
4-
import com.google.protobuf.gradle.*
4+
import com.google.protobuf.gradle.protobuf
5+
import com.google.protobuf.gradle.protoc
6+
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
57

68
buildscript {
79
repositories {
@@ -32,6 +34,7 @@ dependencies {
3234
compile("com.amazonaws:aws-lambda-java-core:1.2.0")
3335
compile("com.amazonaws:aws-lambda-java-events:2.2.5")
3436

37+
3538
compile("org.slf4j:slf4j-api:1.7.26")
3639
compile("com.fasterxml.jackson.core:jackson-databind:2.9.8")
3740
compile("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.8")
@@ -41,6 +44,7 @@ dependencies {
4144

4245
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.4.0")
4346
testImplementation("com.willowtreeapps.assertk:assertk-jvm:0.12")
47+
testImplementation("org.assertj:assertj-core:3.11.1")
4448
testImplementation("io.mockk:mockk:1.8.13.kotlin13")
4549
testImplementation("org.slf4j:slf4j-simple:1.7.26")
4650
}

src/main/kotlin/com/github/mduesterhoeft/router/RequestHandler.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,14 @@ abstract class RequestHandler : RequestHandler<APIGatewayProxyRequestEvent, APIG
2222
override fun handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent? {
2323
log.info("handling request with method '${input.httpMethod}' and path '${input.path}' - Accept:${input.acceptHeader()} Content-Type:${input.contentType()} $input")
2424
val routes = router.routes as List<RouterFunction<Any, Any>>
25-
val matchResults: List<MatchResult> = routes.map { routerFunction: RouterFunction<Any, Any> ->
25+
val matchResults: List<RequestMatchResult> = routes.map { routerFunction: RouterFunction<Any, Any> ->
2626
val matchResult = routerFunction.requestPredicate.match(input)
2727
log.info("match result for route '$routerFunction' is '$matchResult'")
2828
if (matchResult.match) {
2929
val handler: HandlerFunction<Any, Any> = routerFunction.handler
3030
val requestBody = deserializeRequest(handler, input)
3131
val request = Request(input, requestBody)
32-
router.requestPreprocessor(request)
33-
val response = handler(request)
32+
val response = router.filter.then(handler as HandlerFunction<*, *>).invoke(request)
3433
return createResponse(input, response)
3534
}
3635

@@ -51,7 +50,7 @@ abstract class RequestHandler : RequestHandler<APIGatewayProxyRequestEvent, APIG
5150
}
5251
}
5352

54-
private fun handleNonDirectMatch(matchResults: List<MatchResult>, input: APIGatewayProxyRequestEvent): APIGatewayProxyResponseEvent {
53+
private fun handleNonDirectMatch(matchResults: List<RequestMatchResult>, input: APIGatewayProxyRequestEvent): APIGatewayProxyResponseEvent {
5554
// no direct match
5655
if (matchResults.any { it.matchPath && it.matchMethod && !it.matchContentType }) {
5756
return createErrorResponse(

src/main/kotlin/com/github/mduesterhoeft/router/RequestPredicate.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ data class RequestPredicate(
2020
}
2121

2222
internal fun match(request: APIGatewayProxyRequestEvent) =
23-
MatchResult(
23+
RequestMatchResult(
2424
matchPath = pathMatches(request),
2525
matchMethod = methodMatches(request),
2626
matchAcceptType = contentTypeMatches(request.acceptHeader(), produces),
2727
matchContentType = contentTypeMatches(request.contentType(), consumes)
2828
)
2929

30-
private fun pathMatches(request: APIGatewayProxyRequestEvent) = request.path == pathPattern
30+
private fun pathMatches(request: APIGatewayProxyRequestEvent) = UriTemplate.from(pathPattern).matches(request.path)
3131
private fun methodMatches(request: APIGatewayProxyRequestEvent) = method.equals(request.httpMethod, true)
3232
private fun contentTypeMatches(contentType: String?, accepted: Set<String>) =
3333
if (accepted.isEmpty() && contentType == null) true
@@ -37,7 +37,7 @@ data class RequestPredicate(
3737
companion object
3838
}
3939

40-
internal data class MatchResult(
40+
internal data class RequestMatchResult(
4141
val matchPath: Boolean = false,
4242
val matchMethod: Boolean = false,
4343
val matchAcceptType: Boolean = false,

src/main/kotlin/com/github/mduesterhoeft/router/Router.kt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class Router {
99
var defaultConsuming = setOf("application/json", "application/x-protobuf")
1010
var defaultProducing = setOf("application/json", "application/x-protobuf")
1111

12-
var requestPreprocessor: (Request<*>) -> Unit = { _ -> Unit }
12+
var filter: Filter = Filter.NoOp
1313

1414
fun <I, T> GET(pattern: String, handlerFunction: HandlerFunction<I, T>) =
1515
RequestPredicate(
@@ -59,19 +59,25 @@ class Router {
5959
routes += RouterFunction(it, handlerFunction)
6060
}
6161

62-
// the default content types the HandlerFunctions of this router can produce
63-
fun defaultProducing(contentTypes: Set<String>): Router = this.also { defaultProducing = contentTypes }
64-
65-
// the default content types the HandlerFunctions of this router can handle
66-
fun defaultConsuming(contentTypes: Set<String>): Router = this.also { defaultConsuming = contentTypes }
67-
68-
fun withPreprocessor(preprocessorFunction: (Request<*>) -> Unit): Router = this.also { requestPreprocessor = preprocessorFunction }
69-
7062
companion object {
7163
fun router(routes: Router.() -> Unit) = Router().apply(routes)
7264
}
7365
}
7466

67+
interface Filter : (HandlerFunction<*, *>) -> HandlerFunction<*,*> {
68+
companion object {
69+
operator fun invoke(fn: (HandlerFunction<*, *>) -> HandlerFunction<*, *>): Filter = object : Filter {
70+
override operator fun invoke(next: HandlerFunction<*, *>): HandlerFunction<*, *> = fn(next)
71+
}
72+
}
73+
}
74+
75+
val Filter.Companion.NoOp: Filter get() = Filter { next -> { next(it) } }
76+
77+
fun Filter.then(next: Filter): Filter = Filter { this(next(it)) }
78+
79+
fun Filter.then(next: HandlerFunction<*, *>): HandlerFunction<*, *> = { this(next)(it) }
80+
7581
typealias HandlerFunction<I, T> = (request: Request<I>) -> ResponseEntity<T>
7682

7783
data class RouterFunction<I, T>(
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.github.mduesterhoeft.router
2+
3+
import java.net.URLDecoder
4+
import java.util.regex.Pattern
5+
6+
class UriTemplate private constructor(private val template: String) {
7+
private val templateRegex: Regex
8+
private val matches: Sequence<MatchResult>
9+
private val parameterNames: List<String>
10+
11+
init {
12+
matches = URI_TEMPLATE_FORMAT.findAll(template)
13+
parameterNames = matches.map { it.groupValues[1] }.toList()
14+
templateRegex = template.replace(URI_TEMPLATE_FORMAT,
15+
{ notMatched -> Pattern.quote(notMatched) },
16+
{ matched -> if (matched.groupValues[2].isBlank()) "([^/]+)" else "(${matched.groupValues[2]})" }).toRegex()
17+
}
18+
19+
companion object {
20+
private val URI_TEMPLATE_FORMAT = "\\{([^}]+?)(?::([^}]+))?\\}".toRegex()
21+
fun from(template: String) = UriTemplate(template.trimSlashes())
22+
23+
fun String.trimSlashes() = "^(/)?(.*?)(/)?$".toRegex().replace(this) { result -> result.groupValues[2] }
24+
}
25+
26+
fun matches(uri: String): Boolean = templateRegex.matches(uri.trimSlashes())
27+
28+
fun extract(uri: String): Map<String, String> = parameterNames.zip(templateRegex.findParameterValues(uri.trimSlashes())).toMap()
29+
30+
private fun Regex.findParameterValues(uri: String): List<String> =
31+
findAll(uri).first().groupValues.drop(1).map { URLDecoder.decode(it, "UTF-8") }
32+
33+
private fun String.replace(regex: Regex, notMatched: (String) -> String, matched: (MatchResult) -> String): String {
34+
val matches = regex.findAll(this)
35+
val builder = StringBuilder()
36+
var position = 0
37+
for (matchResult in matches) {
38+
val before = substring(position, matchResult.range.start)
39+
if (before.isNotEmpty()) builder.append(notMatched(before))
40+
builder.append(matched(matchResult))
41+
position = matchResult.range.endInclusive + 1
42+
}
43+
val after = substring(position, length)
44+
if (after.isNotEmpty()) builder.append(notMatched(after))
45+
return builder.toString()
46+
}
47+
48+
override fun toString(): String = template
49+
}

src/test/kotlin/com/github/mduesterhoeft/router/RequestHandlerTest.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ class RequestHandlerTest {
2626
assert(response.body).isEqualTo("""{"greeting":"Hello"}""")
2727
}
2828

29+
@Test
30+
fun `should match request with path parameter`() {
31+
32+
val response = testRequestHandler.handleRequest(
33+
APIGatewayProxyRequestEvent()
34+
.withPath("/some/me")
35+
.withHttpMethod("GET")
36+
.withHeaders(mapOf("Accept" to "application/json")), mockk()
37+
)!!
38+
39+
assert(response.statusCode).isEqualTo(200)
40+
assert(response.body).isEqualTo("""{"greeting":"Hello me"}""")
41+
}
42+
2943
@Test
3044
fun `should match request to proto handler and return json`() {
3145

@@ -130,6 +144,40 @@ class RequestHandlerTest {
130144
assert(response.statusCode).isEqualTo(404)
131145
}
132146

147+
@Test
148+
fun `should invoke filter chain`() {
149+
150+
val handler = TestRequestHandlerWithFilter()
151+
val response = handler.handleRequest(
152+
APIGatewayProxyRequestEvent()
153+
.withPath("/some")
154+
.withHttpMethod("GET")
155+
.withHeaders(mapOf("Accept" to "application/json")), mockk()
156+
)!!
157+
158+
assert(response.statusCode).isEqualTo(200)
159+
assert(handler.filterInvocations).isEqualTo(2)
160+
}
161+
162+
class TestRequestHandlerWithFilter : RequestHandler() {
163+
164+
var filterInvocations = 0
165+
166+
private val incrementingFilter = Filter {
167+
next -> {
168+
request ->
169+
filterInvocations += 1
170+
next(request)
171+
}
172+
}
173+
override val router = router {
174+
filter = incrementingFilter.then(incrementingFilter)
175+
176+
GET("/some") { _: Request<Unit> ->
177+
ResponseEntity.ok("hello")
178+
}
179+
}
180+
}
133181
class TestRequestHandler : RequestHandler() {
134182

135183
data class TestResponse(val greeting: String)
@@ -139,6 +187,9 @@ class RequestHandlerTest {
139187
GET("/some") { _: Request<Unit> ->
140188
ResponseEntity.ok(TestResponse("Hello"))
141189
}
190+
GET("/some/{id}") { r: Request<Unit> ->
191+
ResponseEntity.ok(TestResponse("Hello ${UriTemplate.from("/some/{id}").extract(r.apiRequest.path)["id"]}"))
192+
}
142193
GET("/some-proto") { _: Request<Unit> ->
143194
ResponseEntity.ok(Sample.newBuilder().setHello("Hello").build())
144195
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.github.mduesterhoeft.router
2+
3+
import org.assertj.core.api.BDDAssertions.then
4+
import org.junit.jupiter.api.Test
5+
import java.util.UUID
6+
7+
class UriTemplateTest {
8+
9+
@Test
10+
fun `should match without parameter`() {
11+
then(UriTemplate.from("/some").matches("/some")).isTrue()
12+
}
13+
14+
@Test
15+
fun `should not match simple`() {
16+
then(UriTemplate.from("/some").matches("/some-other")).isFalse()
17+
}
18+
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()
23+
}
24+
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+
}
37+
38+
}

0 commit comments

Comments
 (0)