Skip to content

Commit 3340b7e

Browse files
Merge pull request #373 from nsprod/ktor-non-primitive-query-param
Handle query params with non primitive type in Ktor controller
2 parents 2ba8d31 + 439ee92 commit 3340b7e

File tree

18 files changed

+1161
-88
lines changed

18 files changed

+1161
-88
lines changed

end2end-tests/ktor/build.gradle.kts

+18
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ sourceSets {
99

1010
plugins {
1111
kotlin("jvm")
12+
kotlin("plugin.serialization") version "2.1.0"
1213
}
1314

1415
java {
@@ -23,6 +24,7 @@ val ktorVersion: String by rootProject.extra
2324
dependencies {
2425
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
2526
implementation("javax.validation:validation-api:2.0.1.Final")
27+
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
2628
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
2729
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
2830
implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion")
@@ -32,8 +34,10 @@ dependencies {
3234
// ktor server
3335
implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion")
3436
implementation("io.ktor:ktor-serialization-jackson:$ktorVersion")
37+
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
3538
implementation("io.ktor:ktor-server-auth:$ktorVersion")
3639
implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
40+
implementation("io.ktor:ktor-server-data-conversion:$ktorVersion")
3741

3842
// ktor test
3943
testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
@@ -62,10 +66,24 @@ tasks {
6266
listOf("--http-controller-opts", "AUTHENTICATION")
6367
)
6468

69+
val generateKtorInstantDateTimeCode = createCodeGenerationTask(
70+
"generateKtorInstantDateTimeCode",
71+
"src/test/resources/examples/instantDateTime/api.yaml",
72+
listOf("--serialization-library", "KOTLINX_SERIALIZATION")
73+
)
74+
75+
val generateKtorQueryParametersCode = createCodeGenerationTask(
76+
"generateKtorQueryParametersCode",
77+
"src/test/resources/examples/queryParameters/api.yaml",
78+
listOf("--serialization-library", "KOTLINX_SERIALIZATION")
79+
)
80+
6581
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
6682
kotlinOptions.jvmTarget = "17"
6783
dependsOn(generateKtorCode)
6884
dependsOn(generateKtorAuthCode)
85+
dependsOn(generateKtorInstantDateTimeCode)
86+
dependsOn(generateKtorQueryParametersCode)
6987
}
7088

7189

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.cjbooms.fabrikt.servers.ktor
2+
3+
import com.example.controllers.InstantDateTimeController
4+
import com.example.controllers.TypedApplicationCall
5+
import com.example.models.QueryResult
6+
import io.mockk.CapturingSlot
7+
import kotlinx.datetime.Instant
8+
9+
class InstantDateTimeControllerImpl(
10+
private val listCapturingSlot: CapturingSlot<List<Instant>?>,
11+
private val param2CapturingSlot: CapturingSlot<Instant?>
12+
): InstantDateTimeController {
13+
override suspend fun get(
14+
explodeListQueryParam: List<Instant>?,
15+
queryParam2: Instant?,
16+
call: TypedApplicationCall<QueryResult>
17+
) {
18+
listCapturingSlot.captured = explodeListQueryParam
19+
param2CapturingSlot.captured = queryParam2
20+
21+
call.respondTyped(QueryResult(items = emptyList()))
22+
}
23+
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.cjbooms.fabrikt.servers.ktor
2+
3+
import com.example.controllers.InstantDateTimeController.Companion.instantDateTimeRoutes
4+
import io.ktor.client.request.*
5+
import io.ktor.server.plugins.contentnegotiation.*
6+
import io.ktor.server.plugins.dataconversion.*
7+
import io.ktor.server.testing.*
8+
import io.ktor.http.HttpStatusCode
9+
import io.ktor.serialization.kotlinx.json.*
10+
import io.mockk.CapturingSlot
11+
import io.mockk.slot
12+
import kotlinx.datetime.Instant
13+
import org.junit.jupiter.api.Test
14+
import kotlin.test.assertEquals
15+
16+
class KtorInstantDateTimeTest {
17+
18+
@Test
19+
fun `handles correct date`() {
20+
val listCapturingSlot: CapturingSlot<List<Instant>?> = slot()
21+
val param2CapturingSlot: CapturingSlot<Instant?> = slot()
22+
23+
testApplication {
24+
configure(installInstantConverter = true)
25+
26+
routing {
27+
instantDateTimeRoutes(InstantDateTimeControllerImpl(listCapturingSlot, param2CapturingSlot))
28+
}
29+
30+
val response = client.get("/instant-date-time?query_param2=2025-02-16T10:52:46Z")
31+
32+
assertEquals(HttpStatusCode.OK, response.status)
33+
assertEquals(null, listCapturingSlot.captured)
34+
assertEquals(Instant.parse("2025-02-16T10:52:46Z"), param2CapturingSlot.captured)
35+
}
36+
}
37+
38+
@Test
39+
fun `handles correct list of date`() {
40+
val listCapturingSlot: CapturingSlot<List<Instant>?> = slot()
41+
val param2CapturingSlot: CapturingSlot<Instant?> = slot()
42+
43+
testApplication {
44+
configure(installInstantConverter = true)
45+
46+
routing {
47+
instantDateTimeRoutes(InstantDateTimeControllerImpl(listCapturingSlot, param2CapturingSlot))
48+
}
49+
50+
val response = client.get("/instant-date-time?explode_list_query_param=2025-02-16T10:52:46Z&explode_list_query_param=2025-02-16T11:52:46Z&query_param2=2025-02-16T10:52:46Z")
51+
52+
assertEquals(HttpStatusCode.OK, response.status)
53+
assertEquals(listOf(Instant.parse("2025-02-16T10:52:46Z"), Instant.parse("2025-02-16T11:52:46Z")), listCapturingSlot.captured)
54+
assertEquals(Instant.parse("2025-02-16T10:52:46Z"), param2CapturingSlot.captured)
55+
}
56+
}
57+
58+
@Test
59+
fun `returns 400 when date is invalid`() {
60+
val listCapturingSlot: CapturingSlot<List<Instant>?> = slot()
61+
val param2CapturingSlot: CapturingSlot<Instant?> = slot()
62+
63+
testApplication {
64+
configure(installInstantConverter = true)
65+
66+
routing {
67+
instantDateTimeRoutes(InstantDateTimeControllerImpl(listCapturingSlot, param2CapturingSlot))
68+
}
69+
70+
val response = client.get("/instant-date-time?query_param2=20-02-16T10:52:46Z")
71+
72+
assertEquals(HttpStatusCode.BadRequest, response.status)
73+
}
74+
}
75+
76+
@Test
77+
fun `returns 400 when Instant converter is missing`() {
78+
val listCapturingSlot: CapturingSlot<List<Instant>?> = slot()
79+
val param2CapturingSlot: CapturingSlot<Instant?> = slot()
80+
81+
testApplication {
82+
configure(installInstantConverter = false)
83+
84+
routing {
85+
instantDateTimeRoutes(InstantDateTimeControllerImpl(listCapturingSlot, param2CapturingSlot))
86+
}
87+
88+
val response = client.get("/instant-date-time?query_param2=2025-02-16T10:52:46Z")
89+
90+
assertEquals(HttpStatusCode.BadRequest, response.status)
91+
}
92+
}
93+
94+
private fun ApplicationTestBuilder.configure(installInstantConverter: Boolean) {
95+
install(ContentNegotiation) {
96+
json()
97+
}
98+
99+
if (installInstantConverter) {
100+
install(DataConversion) {
101+
convert<Instant> {
102+
decode { values ->
103+
values.single().let { Instant.parse(it) }
104+
}
105+
}
106+
107+
convert<List<Instant>> {
108+
decode { values ->
109+
values.map { Instant.parse(it) }
110+
}
111+
}
112+
}
113+
}
114+
}
115+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package com.cjbooms.fabrikt.servers.ktor
2+
3+
import com.example.controllers.QueryParamsController.Companion.queryParamsRoutes
4+
import com.example.models.EnumQueryParam
5+
import io.ktor.client.request.*
6+
import io.ktor.http.*
7+
import io.ktor.serialization.kotlinx.json.*
8+
import io.ktor.server.plugins.contentnegotiation.*
9+
import io.ktor.server.plugins.dataconversion.DataConversion
10+
import io.ktor.server.testing.*
11+
import io.ktor.util.converters.*
12+
import io.mockk.CapturingSlot
13+
import io.mockk.slot
14+
import org.junit.jupiter.api.Test
15+
import kotlin.test.assertEquals
16+
17+
class KtorQueryParametersTest {
18+
@Test
19+
fun `returns 200 when name required query parameter is present`() {
20+
val nameCapturingSlot: CapturingSlot<String?> = slot()
21+
val enumCapturingSlot: CapturingSlot<EnumQueryParam?> = slot()
22+
23+
testApplication {
24+
install(ContentNegotiation) {
25+
json()
26+
}
27+
28+
routing {
29+
queryParamsRoutes(QueryParametersControllerImpl(nameCapturingSlot, enumCapturingSlot))
30+
}
31+
32+
val response = client.get("/query-params?name=test")
33+
34+
assertEquals(HttpStatusCode.OK, response.status)
35+
assertEquals("test", nameCapturingSlot.captured)
36+
assertEquals(null, enumCapturingSlot.captured)
37+
}
38+
}
39+
40+
@Test
41+
fun `returns 400 when name required query parameter is missing`() {
42+
val nameCapturingSlot: CapturingSlot<String?> = slot()
43+
val enumCapturingSlot: CapturingSlot<EnumQueryParam?> = slot()
44+
45+
testApplication {
46+
install(ContentNegotiation) {
47+
json()
48+
}
49+
50+
routing {
51+
queryParamsRoutes(QueryParametersControllerImpl(nameCapturingSlot, enumCapturingSlot))
52+
}
53+
54+
val response = client.get("/query-params")
55+
56+
assertEquals(HttpStatusCode.BadRequest, response.status)
57+
}
58+
}
59+
60+
@Test
61+
fun `returns 200 when limit parameter is valid`() {
62+
val nameCapturingSlot: CapturingSlot<String?> = slot()
63+
val enumCapturingSlot: CapturingSlot<EnumQueryParam?> = slot()
64+
65+
testApplication {
66+
install(ContentNegotiation) {
67+
json()
68+
}
69+
70+
install(DataConversion) {
71+
convert<EnumQueryParam> {
72+
decode { values ->
73+
values.single().let { EnumQueryParam.fromValue(it) ?: throw DataConversionException() }
74+
}
75+
}
76+
}
77+
78+
routing {
79+
queryParamsRoutes(QueryParametersControllerImpl(nameCapturingSlot, enumCapturingSlot))
80+
}
81+
82+
val response = client.get("/query-params?name=test&order=asc")
83+
84+
assertEquals(HttpStatusCode.OK, response.status)
85+
assertEquals("test", nameCapturingSlot.captured)
86+
assertEquals(EnumQueryParam.ASC, enumCapturingSlot.captured)
87+
}
88+
}
89+
90+
@Test
91+
fun `returns 400 when limit parameter is invalid`() {
92+
val nameCapturingSlot: CapturingSlot<String?> = slot()
93+
val enumCapturingSlot: CapturingSlot<EnumQueryParam?> = slot()
94+
95+
testApplication {
96+
install(ContentNegotiation) {
97+
json()
98+
}
99+
100+
install(DataConversion) {
101+
convert<EnumQueryParam> {
102+
decode { values ->
103+
values.single().let { EnumQueryParam.fromValue(it) ?: throw DataConversionException() }
104+
}
105+
}
106+
}
107+
108+
routing {
109+
queryParamsRoutes(QueryParametersControllerImpl(nameCapturingSlot, enumCapturingSlot))
110+
}
111+
112+
val response = client.get("/query-params?order=invalid")
113+
114+
assertEquals(HttpStatusCode.BadRequest, response.status)
115+
}
116+
}
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.cjbooms.fabrikt.servers.ktor
2+
3+
import com.example.controllers.QueryParamsController
4+
import com.example.controllers.TypedApplicationCall
5+
import com.example.models.EnumQueryParam
6+
import com.example.models.QueryParamsResult
7+
import io.mockk.CapturingSlot
8+
9+
class QueryParametersControllerImpl(
10+
private val nameCapturingSlot: CapturingSlot<String?>,
11+
private val orderCapturingSlot: CapturingSlot<EnumQueryParam?>,
12+
): QueryParamsController {
13+
override suspend fun get(name: String, order: EnumQueryParam?, call: TypedApplicationCall<QueryParamsResult>) {
14+
nameCapturingSlot.captured = name
15+
orderCapturingSlot.captured = order
16+
call.respondTyped(QueryParamsResult("ok"))
17+
}
18+
}

0 commit comments

Comments
 (0)