From fbf1b8e3237658d104391aecfc447307a21fbec8 Mon Sep 17 00:00:00 2001 From: Ivan “CLOVIS” Canet Date: Wed, 01 Jul 2026 19:33:07 +0000 Subject: [PATCH] merge: Fix whitespace being trimmed in request URLs Closes #31 See merge request opensavvy/groundwork/spine!126 --- gradle/libs.versions.toml | 4 ++++ server/build.gradle.kts | 2 ++ client/src/commonMain/kotlin/Client.kt | 4 +++- server/src/commonTest/kotlin/BlankPathRegresssionTest.kt | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ server/src/commonTest/kotlin/ServerTests.kt | 1 + server/src/commonTest/resources/simplelogger.properties | 1 + 6 file(s) changed, 63 insertion(s)(+), 1 deletion(s)(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,8 @@ kotlinx-coroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases +slf4j = "2.0.17" # https://central.sonatype.com/artifact/org.slf4j/slf4j-simple + [plugins] [libraries] @@ -23,5 +25,7 @@ arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } + +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } [bundles] diff --git a/server/build.gradle.kts b/server/build.gradle.kts --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -32,7 +32,9 @@ implementation(libsCommon.kotlin.test) implementation(libs.ktor.server.contentNegotiation) implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.client.logging) implementation(libs.ktor.kotlinxJson) + implementation(libs.slf4j.simple) implementation(projects.client) implementation(projects.clientArrow) } diff --git a/client/src/commonMain/kotlin/Client.kt b/client/src/commonMain/kotlin/Client.kt --- a/client/src/commonMain/kotlin/Client.kt +++ b/client/src/commonMain/kotlin/Client.kt @@ -61,7 +61,9 @@ ): SpineResponse { val result = request { method = endpoint.data.method - url(endpoint.path.toString()) + url { + pathSegments = endpoint.path.segments.map { it.text } + } for ((name, values) in endpoint.data.buildParameters(HashMap()).apply(parameters).data) for (value in values) diff --git a/server/src/commonTest/kotlin/BlankPathRegresssionTest.kt b/server/src/commonTest/kotlin/BlankPathRegresssionTest.kt new file mode 100644 --- /dev/null +++ b/server/src/commonTest/kotlin/BlankPathRegresssionTest.kt @@ -0,0 +1,52 @@ +package opensavvy.spine.typed.server + +import io.ktor.serialization.kotlinx.json.* +import opensavvy.prepared.compat.ktor.preparedClient +import opensavvy.prepared.compat.ktor.preparedServer +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.spine.api.* +import opensavvy.spine.client.bodyOrThrow +import opensavvy.spine.client.request +import opensavvy.spine.server.respond +import opensavvy.spine.server.route +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation + +private object MinimalSpineApi : RootResource("api") { + object Profiles : StaticResource("profiles", MinimalSpineApi) { + object Username : DynamicResource("username", Profiles) { + val get by get() + .response() + } + } +} + +private val blankPathServer by preparedServer { + install(ServerContentNegotiation) { json() } + routing { + route(MinimalSpineApi.Profiles.Username.get) { + val id = idOf(MinimalSpineApi.Profiles.Username) + respond(id) + } + } +} + +private val blankPathClient by blankPathServer.preparedClient { + install(ClientContentNegotiation) { json() } +} + +fun SuiteDsl.blankPathRegressionTest() = suite("Blank path") { + test("URL-encoded spaces should be accepted as path segments") { + val result = blankPathClient().request( + MinimalSpineApi / MinimalSpineApi.Profiles / MinimalSpineApi.Profiles.Username("%20") / MinimalSpineApi.Profiles.Username.get + ).bodyOrThrow() + check(result == "%20") + } + + test("Literal whitespace should be accepted as path segments") { + val result = blankPathClient().request( + MinimalSpineApi / MinimalSpineApi.Profiles / MinimalSpineApi.Profiles.Username(" ") / MinimalSpineApi.Profiles.Username.get + ).bodyOrThrow() + check(result == " ") + } +} diff --git a/server/src/commonTest/kotlin/ServerTests.kt b/server/src/commonTest/kotlin/ServerTests.kt --- a/server/src/commonTest/kotlin/ServerTests.kt +++ b/server/src/commonTest/kotlin/ServerTests.kt @@ -4,4 +4,5 @@ val ServerTests by preparedSuite { routeTest() + blankPathRegressionTest() } diff --git a/server/src/commonTest/resources/simplelogger.properties b/server/src/commonTest/resources/simplelogger.properties new file mode 100644 --- /dev/null +++ b/server/src/commonTest/resources/simplelogger.properties @@ -0,0 +1,1 @@ +org.slf4j.simpleLogger.defaultLogLevel=trace -- tangled.sh