Skip to content

Commit 551863d

Browse files
Merge pull request #375 from ulrikandersen/ktor-non-primitive-path-param
Ktor server: Use DataConversion plugin for non-primitive path param types
2 parents 3340b7e + b85363d commit 551863d

File tree

11 files changed

+534
-31
lines changed

11 files changed

+534
-31
lines changed

end2end-tests/ktor/build.gradle.kts

+9
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ dependencies {
5252
testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
5353
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion")
5454
testImplementation("org.assertj:assertj-core:3.24.2")
55+
56+
testImplementation("ch.qos.logback:logback-classic:1.4.3")
5557
}
5658

5759
tasks {
@@ -78,12 +80,19 @@ tasks {
7880
listOf("--serialization-library", "KOTLINX_SERIALIZATION")
7981
)
8082

83+
val generateKtorPathParametersCode = createCodeGenerationTask(
84+
"generateKtorPathParametersCode",
85+
"src/test/resources/examples/pathParameters/api.yaml",
86+
listOf("--serialization-library", "KOTLINX_SERIALIZATION")
87+
)
88+
8189
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
8290
kotlinOptions.jvmTarget = "17"
8391
dependsOn(generateKtorCode)
8492
dependsOn(generateKtorAuthCode)
8593
dependsOn(generateKtorInstantDateTimeCode)
8694
dependsOn(generateKtorQueryParametersCode)
95+
dependsOn(generateKtorPathParametersCode)
8796
}
8897

8998

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package com.cjbooms.fabrikt.servers.ktor
2+
3+
import com.example.controllers.PathParamsController.Companion.pathParamsRoutes
4+
import com.example.models.PathParamWithEnum
5+
import io.ktor.client.request.get
6+
import io.ktor.http.HttpStatusCode
7+
import io.ktor.server.plugins.BadRequestException
8+
import io.ktor.server.plugins.dataconversion.DataConversion
9+
import io.ktor.server.plugins.statuspages.StatusPages
10+
import io.ktor.server.response.respond
11+
import io.ktor.server.testing.ApplicationTestBuilder
12+
import io.ktor.server.testing.testApplication
13+
import io.mockk.CapturingSlot
14+
import io.mockk.slot
15+
import org.junit.jupiter.api.Test
16+
import java.util.UUID
17+
import kotlin.test.assertEquals
18+
19+
class KtorPathParametersTest {
20+
@Test
21+
fun `handles path parameters of different types using DataConversion plugin`() {
22+
val primitiveParamCapturingSlot = slot<String?>()
23+
val formatParamCapturingSlot = slot<UUID?>()
24+
val enumParamCapturingSlot = slot<PathParamWithEnum?>()
25+
26+
testApplication {
27+
configure(withDataConversion = true)
28+
29+
routing {
30+
pathParamsRoutes(PathParamsControllerImpl(
31+
primitiveParamCapturingSlot,
32+
formatParamCapturingSlot,
33+
enumParamCapturingSlot
34+
))
35+
}
36+
37+
val response = client.get("/path-params/test/123e4567-e89b-12d3-a456-426614174000/active")
38+
39+
assertEquals(HttpStatusCode.NoContent, response.status)
40+
assertEquals("test", primitiveParamCapturingSlot.captured)
41+
assertEquals(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), formatParamCapturingSlot.captured)
42+
assertEquals(PathParamWithEnum.ACTIVE, enumParamCapturingSlot.captured)
43+
}
44+
}
45+
46+
@Test
47+
fun `falls back to DefaultConversion service if DataConversion is not installed`() {
48+
val primitiveParamCapturingSlot = slot<String?>()
49+
val formatParamCapturingSlot = slot<UUID?>()
50+
val enumParamCapturingSlot = slot<PathParamWithEnum?>()
51+
52+
testApplication {
53+
configure(withDataConversion = false)
54+
55+
routing {
56+
pathParamsRoutes(PathParamsControllerImpl(
57+
primitiveParamCapturingSlot,
58+
formatParamCapturingSlot,
59+
enumParamCapturingSlot
60+
))
61+
}
62+
63+
val response = client.get("/path-params/test/123e4567-e89b-12d3-a456-426614174000/ACTIVE") // N.B: DefaultConversionService matches on enum name which is uppercase
64+
65+
assertEquals(HttpStatusCode.NoContent, response.status)
66+
assertEquals("test", primitiveParamCapturingSlot.captured)
67+
assertEquals(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), formatParamCapturingSlot.captured)
68+
assertEquals(PathParamWithEnum.ACTIVE, enumParamCapturingSlot.captured)
69+
}
70+
}
71+
72+
@Test
73+
fun `fails with 400 when conversion fails`() {
74+
val primitiveParamCapturingSlot = slot<String?>()
75+
val formatParamCapturingSlot = slot<UUID?>()
76+
val enumParamCapturingSlot = slot<PathParamWithEnum?>()
77+
val errorCapturingSlot = slot<String?>()
78+
79+
testApplication {
80+
configure(withDataConversion = true, errorCapturingSlot = errorCapturingSlot)
81+
82+
routing {
83+
pathParamsRoutes(PathParamsControllerImpl(
84+
primitiveParamCapturingSlot,
85+
formatParamCapturingSlot,
86+
enumParamCapturingSlot
87+
))
88+
}
89+
90+
val response = client.get("/path-params/test/0000-invalid-uuid-0000/active")
91+
92+
assertEquals(HttpStatusCode.BadRequest, response.status)
93+
assertEquals("Request parameter formatParam couldn't be parsed/converted to UUID", errorCapturingSlot.captured)
94+
}
95+
}
96+
97+
private fun ApplicationTestBuilder.configure(
98+
withDataConversion: Boolean = true,
99+
errorCapturingSlot: CapturingSlot<String?>? = null,
100+
) {
101+
if (withDataConversion) {
102+
install(DataConversion) {
103+
convert<UUID> {
104+
decode { values ->
105+
UUID.fromString(values.single())
106+
}
107+
}
108+
convert<PathParamWithEnum> {
109+
decode { values ->
110+
PathParamWithEnum.entries.find { it.value == values.single() }
111+
?: throw BadRequestException("Invalid enum value ${values.single()}")
112+
}
113+
}
114+
}
115+
}
116+
117+
install(StatusPages) {
118+
exception<BadRequestException> { call, cause ->
119+
errorCapturingSlot?.captured = cause.message
120+
call.respond(HttpStatusCode.BadRequest)
121+
}
122+
}
123+
}
124+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.cjbooms.fabrikt.servers.ktor
2+
3+
import com.example.controllers.PathParamsController
4+
import com.example.models.PathParamWithEnum
5+
import io.ktor.http.HttpStatusCode
6+
import io.ktor.server.application.ApplicationCall
7+
import io.ktor.server.response.respond
8+
import io.mockk.CapturingSlot
9+
import java.util.UUID
10+
11+
class PathParamsControllerImpl(
12+
private val primitiveParamCapturingSlot: CapturingSlot<String?>,
13+
private val formatParamCapturingSlot: CapturingSlot<UUID?>,
14+
private val enumParamCapturingSlot: CapturingSlot<PathParamWithEnum?>,
15+
) : PathParamsController {
16+
override suspend fun getById(
17+
primitiveParam: String,
18+
formatParam: UUID,
19+
enumParam: PathParamWithEnum,
20+
call: ApplicationCall
21+
) {
22+
primitiveParamCapturingSlot.captured = primitiveParam
23+
formatParamCapturingSlot.captured = formatParam
24+
enumParamCapturingSlot.captured = enumParam
25+
26+
call.respond(HttpStatusCode.NoContent)
27+
}
28+
}

src/main/kotlin/com/cjbooms/fabrikt/generators/controller/KtorControllerInterfaceGenerator.kt

+16-5
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,22 @@ class KtorControllerInterfaceGenerator(
196196
.indent()
197197

198198
pathParams.forEach { param ->
199-
builder.addStatement(
200-
"val ${param.name} = %M.parameters.%M<${param.type}>(\"${param.originalName}\")",
201-
MemberName("io.ktor.server.application", "call"),
202-
MemberName("io.ktor.server.util", "getOrFail", isExtension = true),
203-
)
199+
val typeName = param.type.copy(nullable = false) // not nullable because we handle that in the queryParameters.get* below
200+
val queryMethodName = "getTypedOrFail"
201+
if (param.requiresKtorDataConversionPlugin()) {
202+
builder.addStatement(
203+
"val ${param.name} = %M.parameters.%M<${typeName}>(\"${param.originalName}\", call.application.%M)",
204+
MemberName("io.ktor.server.application", "call"),
205+
MemberName(packages.controllers, queryMethodName),
206+
MemberName("io.ktor.server.plugins.dataconversion", "conversionService"),
207+
)
208+
} else {
209+
builder.addStatement(
210+
"val ${param.name} = %M.parameters.%M<${typeName}>(\"${param.originalName}\")",
211+
MemberName("io.ktor.server.application", "call"),
212+
MemberName(packages.controllers, queryMethodName),
213+
)
214+
}
204215
}
205216

206217
headerParams.forEach { param ->

src/test/kotlin/com/cjbooms/fabrikt/generators/KtorControllerInterfaceGeneratorTest.kt

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class KtorControllerInterfaceGeneratorTest {
3838
"requestBodyAsArray",
3939
"jakartaValidationAnnotations",
4040
"modelSuffix",
41+
"queryParameters",
42+
"pathParameters",
4143
)
4244

4345
private fun setupGithubApiTestEnv() {

src/test/resources/examples/githubApi/controllers/ktor/Controllers.kt

+19-20
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import io.ktor.server.routing.delete
2727
import io.ktor.server.routing.`get`
2828
import io.ktor.server.routing.post
2929
import io.ktor.server.routing.put
30-
import io.ktor.server.util.getOrFail
3130
import io.ktor.util.converters.ConversionService
3231
import io.ktor.util.converters.DefaultConversionService
3332
import io.ktor.util.reflect.typeInfo
@@ -267,7 +266,7 @@ public interface ContributorsController {
267266
controller.createContributor(xFlowId, idempotencyKey, contributor, call)
268267
}
269268
`get`("/contributors/{id}") {
270-
val id = call.parameters.getOrFail<kotlin.String>("id")
269+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
271270
val xFlowId = call.request.headers["X-Flow-Id"]
272271
val ifNoneMatch = call.request.headers["If-None-Match"]
273272
val status =
@@ -278,7 +277,7 @@ public interface ContributorsController {
278277
controller.getContributor(xFlowId, ifNoneMatch, id, status, TypedApplicationCall(call))
279278
}
280279
put("/contributors/{id}") {
281-
val id = call.parameters.getOrFail<kotlin.String>("id")
280+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
282281
val ifMatch = call.request.headers.getOrFail("If-Match")
283282
val xFlowId = call.request.headers["X-Flow-Id"]
284283
val idempotencyKey = call.request.headers["Idempotency-Key"]
@@ -482,7 +481,7 @@ public interface OrganisationsController {
482481
controller.post(xFlowId, idempotencyKey, organisation, call)
483482
}
484483
`get`("/organisations/{id}") {
485-
val id = call.parameters.getOrFail<kotlin.String>("id")
484+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
486485
val xFlowId = call.request.headers["X-Flow-Id"]
487486
val ifNoneMatch = call.request.headers["If-None-Match"]
488487
val status =
@@ -493,7 +492,7 @@ public interface OrganisationsController {
493492
controller.getById(xFlowId, ifNoneMatch, id, status, TypedApplicationCall(call))
494493
}
495494
put("/organisations/{id}") {
496-
val id = call.parameters.getOrFail<kotlin.String>("id")
495+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
497496
val ifMatch = call.request.headers.getOrFail("If-Match")
498497
val xFlowId = call.request.headers["X-Flow-Id"]
499498
val idempotencyKey = call.request.headers["Idempotency-Key"]
@@ -683,7 +682,7 @@ public interface OrganisationsContributorsController {
683682
*/
684683
public fun Route.organisationsContributorsRoutes(controller: OrganisationsContributorsController) {
685684
`get`("/organisations/{parent-id}/contributors") {
686-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
685+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
687686
val xFlowId = call.request.headers["X-Flow-Id"]
688687
val limit = call.request.queryParameters.getTyped<kotlin.Int>("limit")
689688
val includeInactive =
@@ -699,23 +698,23 @@ public interface OrganisationsContributorsController {
699698
)
700699
}
701700
`get`("/organisations/{parent-id}/contributors/{id}") {
702-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
703-
val id = call.parameters.getOrFail<kotlin.String>("id")
701+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
702+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
704703
val xFlowId = call.request.headers["X-Flow-Id"]
705704
val ifNoneMatch = call.request.headers["If-None-Match"]
706705
controller.getById(xFlowId, ifNoneMatch, parentId, id, TypedApplicationCall(call))
707706
}
708707
put("/organisations/{parent-id}/contributors/{id}") {
709-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
710-
val id = call.parameters.getOrFail<kotlin.String>("id")
708+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
709+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
711710
val ifMatch = call.request.headers.getOrFail("If-Match")
712711
val xFlowId = call.request.headers["X-Flow-Id"]
713712
val idempotencyKey = call.request.headers["Idempotency-Key"]
714713
controller.putById(ifMatch, xFlowId, idempotencyKey, parentId, id, call)
715714
}
716715
delete("/organisations/{parent-id}/contributors/{id}") {
717-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
718-
val id = call.parameters.getOrFail<kotlin.String>("id")
716+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
717+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
719718
val xFlowId = call.request.headers["X-Flow-Id"]
720719
controller.deleteById(xFlowId, parentId, id, call)
721720
}
@@ -932,7 +931,7 @@ public interface RepositoriesController {
932931
controller.post(xFlowId, idempotencyKey, repository, call)
933932
}
934933
`get`("/repositories/{id}") {
935-
val id = call.parameters.getOrFail<kotlin.String>("id")
934+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
936935
val xFlowId = call.request.headers["X-Flow-Id"]
937936
val ifNoneMatch = call.request.headers["If-None-Match"]
938937
val status =
@@ -943,7 +942,7 @@ public interface RepositoriesController {
943942
controller.getById(xFlowId, ifNoneMatch, id, status, TypedApplicationCall(call))
944943
}
945944
put("/repositories/{id}") {
946-
val id = call.parameters.getOrFail<kotlin.String>("id")
945+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
947946
val ifMatch = call.request.headers.getOrFail("If-Match")
948947
val xFlowId = call.request.headers["X-Flow-Id"]
949948
val idempotencyKey = call.request.headers["Idempotency-Key"]
@@ -1143,7 +1142,7 @@ public interface RepositoriesPullRequestsController {
11431142
*/
11441143
public fun Route.repositoriesPullRequestsRoutes(controller: RepositoriesPullRequestsController) {
11451144
`get`("/repositories/{parent-id}/pull-requests") {
1146-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
1145+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
11471146
val xFlowId = call.request.headers["X-Flow-Id"]
11481147
val limit = call.request.queryParameters.getTyped<kotlin.Int>("limit")
11491148
val includeInactive =
@@ -1159,22 +1158,22 @@ public interface RepositoriesPullRequestsController {
11591158
)
11601159
}
11611160
post("/repositories/{parent-id}/pull-requests") {
1162-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
1161+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
11631162
val xFlowId = call.request.headers["X-Flow-Id"]
11641163
val idempotencyKey = call.request.headers["Idempotency-Key"]
11651164
val pullRequest = call.receive<PullRequest>()
11661165
controller.post(xFlowId, idempotencyKey, parentId, pullRequest, call)
11671166
}
11681167
`get`("/repositories/{parent-id}/pull-requests/{id}") {
1169-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
1170-
val id = call.parameters.getOrFail<kotlin.String>("id")
1168+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
1169+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
11711170
val xFlowId = call.request.headers["X-Flow-Id"]
11721171
val ifNoneMatch = call.request.headers["If-None-Match"]
11731172
controller.getById(xFlowId, ifNoneMatch, parentId, id, TypedApplicationCall(call))
11741173
}
11751174
put("/repositories/{parent-id}/pull-requests/{id}") {
1176-
val parentId = call.parameters.getOrFail<kotlin.String>("parent-id")
1177-
val id = call.parameters.getOrFail<kotlin.String>("id")
1175+
val parentId = call.parameters.getTypedOrFail<kotlin.String>("parent-id")
1176+
val id = call.parameters.getTypedOrFail<kotlin.String>("id")
11781177
val ifMatch = call.request.headers.getOrFail("If-Match")
11791178
val xFlowId = call.request.headers["X-Flow-Id"]
11801179
val idempotencyKey = call.request.headers["Idempotency-Key"]

src/test/resources/examples/jakartaValidationAnnotations/controllers/ktor/Controllers.kt

+3-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import io.ktor.server.plugins.ParameterConversionException
1111
import io.ktor.server.response.respond
1212
import io.ktor.server.routing.Route
1313
import io.ktor.server.routing.`get`
14-
import io.ktor.server.util.getOrFail
1514
import io.ktor.util.converters.ConversionService
1615
import io.ktor.util.converters.DefaultConversionService
1716
import io.ktor.util.reflect.typeInfo
@@ -45,7 +44,7 @@ public interface MaximumTestController {
4544
*/
4645
public fun Route.maximumTestRoutes(controller: MaximumTestController) {
4746
`get`("/maximumTest/{pathId}") {
48-
val pathId = call.parameters.getOrFail<kotlin.Long?>("pathId")
47+
val pathId = call.parameters.getTypedOrFail<kotlin.Long>("pathId")
4948
val headerid = call.request.headers["headerid"]
5049
val queryid = call.request.queryParameters.getTyped<kotlin.Long>("queryid")
5150
controller.getById(headerid, pathId, queryid, call)
@@ -141,7 +140,7 @@ public interface MinimumTestController {
141140
*/
142141
public fun Route.minimumTestRoutes(controller: MinimumTestController) {
143142
`get`("/minimumTest/{pathId}") {
144-
val pathId = call.parameters.getOrFail<kotlin.Long?>("pathId")
143+
val pathId = call.parameters.getTypedOrFail<kotlin.Long>("pathId")
145144
val headerid = call.request.headers["headerid"]
146145
val queryid = call.request.queryParameters.getTyped<kotlin.Long>("queryid")
147146
controller.getById(headerid, pathId, queryid, call)
@@ -237,7 +236,7 @@ public interface MinMaxTestController {
237236
*/
238237
public fun Route.minMaxTestRoutes(controller: MinMaxTestController) {
239238
`get`("/minMaxTest/{pathId}") {
240-
val pathId = call.parameters.getOrFail<kotlin.Long?>("pathId")
239+
val pathId = call.parameters.getTypedOrFail<kotlin.Long>("pathId")
241240
val headerid = call.request.headers["headerid"]
242241
val queryid = call.request.queryParameters.getTyped<kotlin.Long>("queryid")
243242
controller.getById(headerid, pathId, queryid, call)

0 commit comments

Comments
 (0)