From 23339b12427d44b79f1b64e494390148d04b1528 Mon Sep 17 00:00:00 2001 From: Leonid Stashevsky Date: Wed, 18 Jan 2023 10:55:03 +0300 Subject: [PATCH] KTOR-5410 Fix server ContentConverter not triggered (#3358) * KTOR-5410 Fix server ContentConverter not triggered --- .../build.gradle.kts | 10 ++ .../jvm/test/ContentNegotiationJvmTest.kt | 154 ++++++++++++++++++ .../contentnegotiation/RequestConverter.kt | 2 +- .../jvmAndNix/test}/ContentNegotiationTest.kt | 131 +++++++++++++-- .../test/ContentNegotiationTestData.kt | 29 ++++ .../server/testing/TestApplicationRequest.kt | 44 +++++ .../server/plugins/ContentNegotiationTest.kt | 125 -------------- .../testing/TestApplicationEngineTest.kt | 55 ++++++- .../server/http/TestEngineMultipartTest.kt | 50 +++++- 9 files changed, 450 insertions(+), 150 deletions(-) create mode 100644 ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvm/test/ContentNegotiationJvmTest.kt rename ktor-server/{ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/plugins => ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test}/ContentNegotiationTest.kt (87%) create mode 100644 ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test/ContentNegotiationTestData.kt delete mode 100644 ktor-server/ktor-server-tests/jvm/test/io/ktor/server/plugins/ContentNegotiationTest.kt diff --git a/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/build.gradle.kts b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/build.gradle.kts index 79f34aec3fe..8b032fe3b8e 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/build.gradle.kts +++ b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/build.gradle.kts @@ -3,3 +3,13 @@ */ description = "" + +kotlin { + sourceSets { + jvmAndNixTest { + dependencies { + implementation(project(":ktor-server:ktor-server-plugins:ktor-server-double-receive")) + } + } + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvm/test/ContentNegotiationJvmTest.kt b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvm/test/ContentNegotiationJvmTest.kt new file mode 100644 index 00000000000..0d39e197073 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvm/test/ContentNegotiationJvmTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ +package io.ktor.server.plugins.contentnegotiation + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.serialization.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import io.ktor.server.testing.internal.* +import io.ktor.util.reflect.* +import io.ktor.utils.io.* +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.coroutines.* +import java.io.* +import kotlin.test.* +import kotlin.text.toByteArray + +class ContentNegotiationJvmTest { + private val alwaysFailingConverter = object : ContentConverter { + override suspend fun serializeNullable( + contentType: ContentType, + charset: Charset, + typeInfo: TypeInfo, + value: Any? + ): OutgoingContent? { + fail("This converter should be never started for send") + } + + override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { + fail("This converter should be never started for receive") + } + } + + @Test + fun testReceiveInputStreamTransformedByDefault(): Unit = testApplication { + application { + install(ContentNegotiation) { + // Order here matters. The first registered content type matching the Accept header will be chosen. + register(ContentType.Any, alwaysFailingConverter) + } + + routing { + post("/input-stream") { + val size = call.receive().readBytes().size + call.respondText("bytes from IS: $size") + } + post("/multipart") { + val multipart = call.receiveMultipart() + val parts = multipart.readAllParts() + call.respondText("parts: ${parts.map { it.name }}") + } + } + } + + assertEquals("bytes from IS: 3", client.post("/input-stream") { setBody("123") }.bodyAsText()) + client.post("/multipart") { + setBody( + buildMultipart( + "my-boundary", + listOf( + PartData.FormItem( + "test", + {}, + headersOf( + HttpHeaders.ContentDisposition, + ContentDisposition( + "form-data", + listOf( + HeaderValueParam("name", "field1") + ) + ).toString() + ) + ) + ) + ) + ) + header( + HttpHeaders.ContentType, + ContentType.MultiPart.FormData.withParameter("boundary", "my-boundary").toString() + ) + }.let { response -> + assertEquals("parts: [field1]", response.bodyAsText()) + } + } + + @Test + fun testRespondInputStream() = testApplication { + application { + routing { + install(ContentNegotiation) { + register(ContentType.Application.Json, alwaysFailingConverter) + } + get("/") { + call.respond(ByteArrayInputStream("""{"x": 123}""".toByteArray())) + } + } + } + val response = client.get("/").bodyAsText() + assertEquals("""{"x": 123}""", response) + } +} + +@OptIn(DelicateCoroutinesApi::class) +internal fun buildMultipart( + boundary: String, + parts: List +): ByteReadChannel = GlobalScope.writer(Dispatchers.IO) { + if (parts.isEmpty()) return@writer + + try { + append("\r\n\r\n") + parts.forEach { + append("--$boundary\r\n") + for ((key, values) in it.headers.entries()) { + append("$key: ${values.joinToString(";")}\r\n") + } + append("\r\n") + append( + when (it) { + is PartData.FileItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.BinaryItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.FormItem -> it.value + is PartData.BinaryChannelItem -> { + it.provider().copyTo(channel) + "" + } + } + ) + append("\r\n") + } + + append("--$boundary--\r\n") + } finally { + parts.forEach { it.dispose() } + } +}.channel + +private suspend fun WriterScope.append(str: String, charset: Charset = Charsets.UTF_8) { + channel.writeFully(str.toByteArray(charset)) +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/src/io/ktor/server/plugins/contentnegotiation/RequestConverter.kt b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/src/io/ktor/server/plugins/contentnegotiation/RequestConverter.kt index af3555518a2..88d6102404a 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/src/io/ktor/server/plugins/contentnegotiation/RequestConverter.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/src/io/ktor/server/plugins/contentnegotiation/RequestConverter.kt @@ -73,6 +73,6 @@ private suspend fun convertBody( convertedBody != null -> convertedBody !body.isClosedForRead -> body receiveType.kotlinType?.isMarkedNullable == true -> NullBody - else -> throw BadRequestException("Cannot convert request body to ${receiveType.type}") + else -> null } } diff --git a/ktor-server/ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/plugins/ContentNegotiationTest.kt b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test/ContentNegotiationTest.kt similarity index 87% rename from ktor-server/ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/plugins/ContentNegotiationTest.kt rename to ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test/ContentNegotiationTest.kt index b79e5900f5a..c05fa3dbc2d 100644 --- a/ktor-server/ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/plugins/ContentNegotiationTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test/ContentNegotiationTest.kt @@ -1,9 +1,10 @@ /* - * Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2014-2023 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ -package io.ktor.tests.server.plugins +package io.ktor.server.plugins.contentnegotiation +import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* @@ -11,7 +12,6 @@ import io.ktor.http.content.* import io.ktor.serialization.* import io.ktor.server.application.* import io.ktor.server.plugins.* -import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.doublereceive.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -25,26 +25,122 @@ import kotlin.test.* @Suppress("DEPRECATION") class ContentNegotiationTest { - private val customContentType = ContentType.parse("application/ktor") - private val customContentConverter = object : ContentConverter { + private val alwaysFailingConverter = object : ContentConverter { override suspend fun serializeNullable( contentType: ContentType, charset: Charset, typeInfo: TypeInfo, value: Any? ): OutgoingContent? { - if (value !is Wrapper) return null - return TextContent("[${value.value}]", contentType.withCharset(charset)) + fail("This converter should be never started for send") } override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { - if (typeInfo.type != Wrapper::class) return null - return Wrapper(content.readRemaining().readText().removeSurrounding("[", "]")) + fail("This converter should be never started for receive") } } - private val textContentConverter = object : ContentConverter { + @Test + fun testRespondByteArray() = testApplication { + application { + routing { + install(ContentNegotiation) { + register(ContentType.Application.Json, alwaysFailingConverter) + } + get("/") { + call.respond("test".toByteArray()) + } + } + } + val response = client.get("/").body() + assertContentEquals("test".toByteArray(), response) + } + + object OK + + @Test + fun testMultipleConverters() = testApplication { + var nullSerialized = false + var nullDeserialized = false + var okSerialized = false + var okDeserialized = false + + application { + routing { + install(ContentNegotiation) { + val nullConverter = object : ContentConverter { + override suspend fun serializeNullable( + contentType: ContentType, + charset: Charset, + typeInfo: TypeInfo, + value: Any? + ): OutgoingContent? { + nullSerialized = true + return null + } + + override suspend fun deserialize( + charset: Charset, + typeInfo: TypeInfo, + content: ByteReadChannel + ): Any? { + nullDeserialized = true + return null + } + } + val okConverter = object : ContentConverter { + override suspend fun serializeNullable( + contentType: ContentType, + charset: Charset, + typeInfo: TypeInfo, + value: Any? + ): OutgoingContent { + okSerialized = true + return TextContent("OK", contentType) + } + + override suspend fun deserialize( + charset: Charset, + typeInfo: TypeInfo, + content: ByteReadChannel + ): Any { + okDeserialized = true + return OK + } + } + + register(ContentType.Application.Json, nullConverter) + register(ContentType.Application.Json, okConverter) + } + + get("/FOO") { + try { + call.receive() + call.respond(OK) + } catch (cause: Throwable) { + call.respond(cause) + } + } + } + } + + val response = client.get("FOO") { + accept(ContentType.Application.Json) + contentType(ContentType.Application.Json) + } + + assertEquals("OK", response.bodyAsText()) + + assertTrue(nullSerialized) + assertTrue(nullDeserialized) + assertTrue(okSerialized) + assertTrue(okDeserialized) + } + + private val customContentType = ContentType.parse("application/ktor") + + private val customContentConverter = object : ContentConverter { override suspend fun serializeNullable( contentType: ContentType, charset: Charset, @@ -52,28 +148,29 @@ class ContentNegotiationTest { value: Any? ): OutgoingContent? { if (value !is Wrapper) return null - return TextContent(value.value, contentType.withCharset(charset)) + return TextContent("[${value.value}]", contentType.withCharset(charset)) } override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { if (typeInfo.type != Wrapper::class) return null - return Wrapper(content.readRemaining().readText()) + return Wrapper(content.readRemaining().readText().removeSurrounding("[", "]")) } } - private fun alwaysFailingConverter(ignoreString: Boolean) = object : ContentConverter { + private val textContentConverter = object : ContentConverter { override suspend fun serializeNullable( contentType: ContentType, charset: Charset, typeInfo: TypeInfo, value: Any? ): OutgoingContent? { - fail("This converter should be never started for send") + if (value !is Wrapper) return null + return TextContent(value.value, contentType.withCharset(charset)) } override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { - if (ignoreString && typeInfo.type == String::class) return null - fail("This converter should be never started for receive") + if (typeInfo.type != Wrapper::class) return null + return Wrapper(content.readRemaining().readText()) } } @@ -444,7 +541,7 @@ class ContentNegotiationTest { } } - client.post("/text") { + client.post("/text") { setBody("\"k=v\"") contentType(ContentType.Application.Json) }.let { response -> diff --git a/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test/ContentNegotiationTestData.kt b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test/ContentNegotiationTestData.kt new file mode 100644 index 00000000000..fe2dff54998 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-content-negotiation/jvmAndNix/test/ContentNegotiationTestData.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.plugins.contentnegotiation + +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.serialization.* +import io.ktor.util.reflect.* +import io.ktor.utils.io.* +import io.ktor.utils.io.charsets.* +import kotlin.test.* + +internal fun alwaysFailingConverter(ignoreString: Boolean) = object : ContentConverter { + override suspend fun serializeNullable( + contentType: ContentType, + charset: Charset, + typeInfo: TypeInfo, + value: Any? + ): OutgoingContent? { + fail("This converter should be never started for send") + } + + override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { + if (ignoreString && typeInfo.type == String::class) return null + fail("This converter should be never started for receive") + } +} diff --git a/ktor-server/ktor-server-test-host/jvmAndNix/src/io/ktor/server/testing/TestApplicationRequest.kt b/ktor-server/ktor-server-test-host/jvmAndNix/src/io/ktor/server/testing/TestApplicationRequest.kt index 2060d9a804e..df2933b7633 100644 --- a/ktor-server/ktor-server-test-host/jvmAndNix/src/io/ktor/server/testing/TestApplicationRequest.kt +++ b/ktor-server/ktor-server-test-host/jvmAndNix/src/io/ktor/server/testing/TestApplicationRequest.kt @@ -180,6 +180,50 @@ public fun TestApplicationRequest.setBody(boundary: String, parts: List +): ByteReadChannel = GlobalScope.writer(Dispatchers.IOBridge) { + if (parts.isEmpty()) return@writer + + try { + append("\r\n\r\n") + parts.forEach { + append("--$boundary\r\n") + for ((key, values) in it.headers.entries()) { + append("$key: ${values.joinToString(";")}\r\n") + } + append("\r\n") + append( + when (it) { + is PartData.FileItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.BinaryItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.FormItem -> it.value + is PartData.BinaryChannelItem -> { + it.provider().copyTo(channel) + "" + } + } + ) + append("\r\n") + } + + append("--$boundary--\r\n") + } finally { + parts.forEach { it.dispose() } + } +}.channel + private suspend fun WriterScope.append(str: String, charset: Charset = Charsets.UTF_8) { channel.writeFully(str.toByteArray(charset)) } diff --git a/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/plugins/ContentNegotiationTest.kt b/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/plugins/ContentNegotiationTest.kt deleted file mode 100644 index 1ca037d0b70..00000000000 --- a/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/plugins/ContentNegotiationTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. - */ - -package io.ktor.server.plugins - -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.serialization.* -import io.ktor.server.application.* -import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.server.testing.* -import io.ktor.util.reflect.* -import io.ktor.utils.io.* -import io.ktor.utils.io.charsets.* -import java.io.* -import kotlin.test.* - -@Suppress("DEPRECATION") -class ContentNegotiationTest { - - private val alwaysFailingConverter = object : ContentConverter { - override suspend fun serializeNullable( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any? - ): OutgoingContent? { - fail("This converter should be never started for send") - } - - override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { - fail("This converter should be never started for receive") - } - } - - @Test - fun testReceiveInputStreamTransformedByDefault(): Unit = withTestApplication { - application.install(ContentNegotiation) { - // Order here matters. The first registered content type matching the Accept header will be chosen. - register(ContentType.Any, alwaysFailingConverter) - } - - application.routing { - post("/input-stream") { - val size = call.receive().readBytes().size - call.respondText("bytes from IS: $size") - } - post("/multipart") { - val multipart = call.receiveMultipart() - val parts = multipart.readAllParts() - call.respondText("parts: ${parts.map { it.name }}") - } - } - - handleRequest(HttpMethod.Post, "/input-stream", { setBody("123") }).let { call -> - assertEquals("bytes from IS: 3", call.response.content) - } - - handleRequest(HttpMethod.Post, "/multipart") { - setBody( - "my-boundary", - listOf( - PartData.FormItem( - "test", - {}, - headersOf( - HttpHeaders.ContentDisposition, - ContentDisposition( - "form-data", - listOf( - HeaderValueParam("name", "field1") - ) - ).toString() - ) - ) - ) - ) - addHeader( - HttpHeaders.ContentType, - ContentType.MultiPart.FormData.withParameter("boundary", "my-boundary").toString() - ) - }.let { call -> - assertEquals("parts: [field1]", call.response.content) - } - } - - @Test - fun testRespondByteArray() = testApplication { - application { - routing { - install(ContentNegotiation) { - register(ContentType.Application.Json, alwaysFailingConverter) - } - get("/") { - call.respond("test".toByteArray()) - } - } - } - val response = client.get("/").body() - assertContentEquals("test".toByteArray(), response) - } - - @Test - fun testRespondInputStream() = testApplication { - application { - routing { - install(ContentNegotiation) { - register(ContentType.Application.Json, alwaysFailingConverter) - } - get("/") { - call.respond(ByteArrayInputStream("""{"x": 123}""".toByteArray())) - } - } - } - val response = client.get("/").bodyAsText() - assertEquals("""{"x": 123}""", response) - } -} diff --git a/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/testing/TestApplicationEngineTest.kt b/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/testing/TestApplicationEngineTest.kt index 4178e812a8a..89ac6420ddb 100644 --- a/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/testing/TestApplicationEngineTest.kt +++ b/ktor-server/ktor-server-tests/jvm/test/io/ktor/server/testing/TestApplicationEngineTest.kt @@ -13,6 +13,8 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.sessions.* +import io.ktor.utils.io.* +import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* import kotlinx.coroutines.* import java.lang.Runnable @@ -20,15 +22,13 @@ import java.util.concurrent.atomic.* import kotlin.coroutines.* import kotlin.system.* import kotlin.test.* +import kotlin.text.Charsets @Suppress("DEPRECATION") class TestApplicationEngineTest { @Test fun testCustomDispatcher() { - @OptIn( - ExperimentalCoroutinesApi::class, - InternalCoroutinesApi::class - ) + @OptIn(InternalCoroutinesApi::class) fun CoroutineDispatcher.withDelay(delay: Delay): CoroutineDispatcher = object : CoroutineDispatcher(), Delay by delay { override fun isDispatchNeeded(context: CoroutineContext): Boolean = @@ -277,9 +277,54 @@ class TestApplicationEngineTest { HttpHeaders.ContentType, ContentType.MultiPart.FormData.withParameter("boundary", boundary).toString() ) - setBody(boundary, multipart) + bodyChannel = buildMultipart(boundary, multipart) } assertEquals(HttpStatusCode.OK, response.response.status()) } } } + +@OptIn(DelicateCoroutinesApi::class) +internal fun buildMultipart( + boundary: String, + parts: List +): ByteReadChannel = GlobalScope.writer { + if (parts.isEmpty()) return@writer + + try { + append("\r\n\r\n") + parts.forEach { + append("--$boundary\r\n") + for ((key, values) in it.headers.entries()) { + append("$key: ${values.joinToString(";")}\r\n") + } + append("\r\n") + append( + when (it) { + is PartData.FileItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.BinaryItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.FormItem -> it.value + is PartData.BinaryChannelItem -> { + it.provider().copyTo(channel) + "" + } + } + ) + append("\r\n") + } + + append("--$boundary--\r\n") + } finally { + parts.forEach { it.dispose() } + } +}.channel + +private suspend fun WriterScope.append(str: String, charset: Charset = Charsets.UTF_8) { + channel.writeFully(str.toByteArray(charset)) +} diff --git a/ktor-server/ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/http/TestEngineMultipartTest.kt b/ktor-server/ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/http/TestEngineMultipartTest.kt index f404a2f627a..efa3547426a 100644 --- a/ktor-server/ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/http/TestEngineMultipartTest.kt +++ b/ktor-server/ktor-server-tests/jvmAndNix/test/io/ktor/tests/server/http/TestEngineMultipartTest.kt @@ -11,6 +11,7 @@ import io.ktor.server.request.* import io.ktor.server.testing.* import io.ktor.util.* import io.ktor.utils.io.* +import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* import kotlinx.coroutines.* import kotlin.test.* @@ -70,7 +71,7 @@ class TestEngineMultipartTest { }, setup = { addHeader(HttpHeaders.ContentType, contentType.toString()) - setBody( + bodyChannel = buildMultipart( boundary, listOf( PartData.FileItem( @@ -142,7 +143,7 @@ class TestEngineMultipartTest { }, setup = { addHeader(HttpHeaders.ContentType, contentType.toString()) - setBody( + bodyChannel = buildMultipart( boundary, listOf( PartData.FileItem( @@ -162,3 +163,48 @@ class TestEngineMultipartTest { ) } } + +@OptIn(DelicateCoroutinesApi::class) +internal fun buildMultipart( + boundary: String, + parts: List +): ByteReadChannel = GlobalScope.writer { + if (parts.isEmpty()) return@writer + + try { + append("\r\n\r\n") + parts.forEach { + append("--$boundary\r\n") + for ((key, values) in it.headers.entries()) { + append("$key: ${values.joinToString(";")}\r\n") + } + append("\r\n") + append( + when (it) { + is PartData.FileItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.BinaryItem -> { + channel.writeFully(it.provider().readBytes()) + "" + } + is PartData.FormItem -> it.value + is PartData.BinaryChannelItem -> { + it.provider().copyTo(channel) + "" + } + } + ) + append("\r\n") + } + + append("--$boundary--\r\n") + } finally { + parts.forEach { it.dispose() } + } +}.channel + +private suspend fun WriterScope.append(str: String, charset: Charset = Charsets.UTF_8) { + channel.writeFully(str.toByteArray(charset)) +}