Skip to content

Commit

Permalink
KTOR-6632 Support receiving multipart data with Ktor client (#4458)
Browse files Browse the repository at this point in the history
  • Loading branch information
e5l authored and osipxd committed Dec 6, 2024
1 parent aea3665 commit 3a1b6c1
Show file tree
Hide file tree
Showing 12 changed files with 229 additions and 363 deletions.
13 changes: 13 additions & 0 deletions buildSrc/src/main/kotlin/test/server/tests/MultiPartFormData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

package test.server.tests

import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
Expand Down Expand Up @@ -34,6 +35,18 @@ internal fun Application.multiPartFormDataTest() {
call.receiveMultipart().readPart()
call.respond(HttpStatusCode.OK)
}
post("receive") {
val multipart = MultiPartFormDataContent(
formData {
append("text", "Hello, World!")
append("file", ByteArray(1024) { it.toByte() }, Headers.build {
append(HttpHeaders.ContentDisposition, """form-data; name="file"; filename="test.bin"""")
append(HttpHeaders.ContentType, ContentType.Application.OctetStream.toString())
})
}
)
call.respond(multipart)
}
}
}
}
1 change: 1 addition & 0 deletions ktor-client/ktor-client-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ kotlin.sourceSets {
commonMain {
dependencies {
api(project(":ktor-http"))
api(project(":ktor-http:ktor-http-cio"))
api(project(":ktor-shared:ktor-events"))
api(project(":ktor-shared:ktor-websocket-serialization"))
api(project(":ktor-shared:ktor-sse"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.http.cio.*
import io.ktor.http.content.*
import io.ktor.util.logging.*
import io.ktor.utils.io.*
Expand Down Expand Up @@ -117,6 +118,22 @@ public fun HttpClient.defaultTransformers() {
proceedWith(HttpResponseContainer(info, response.status))
}

MultiPartData::class -> {
val rawContentType = checkNotNull(context.response.headers[HttpHeaders.ContentType]) {
"No content type provided for multipart"
}
val contentType = ContentType.parse(rawContentType)
check(contentType.match(ContentType.MultiPart.FormData)) {
"Expected multipart/form-data, got $contentType"
}

val contentLength = context.response.headers[HttpHeaders.ContentLength]?.toLong()
val body = CIOMultipartDataBase(coroutineContext, body, rawContentType, contentLength)
val parsedResponse = HttpResponseContainer(info, body)

proceedWith(parsedResponse)
}

else -> null
}
if (result != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

package io.ktor.client.tests

import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.tests.utils.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.utils.io.*
import kotlinx.io.*
import kotlin.test.*
import kotlin.time.*

/**
* Tests client request with multi-part form data.
Expand Down Expand Up @@ -48,4 +50,41 @@ class MultiPartFormDataTest : ClientLoader() {
assertTrue(response.status.isSuccess())
}
}

@Test
fun testReceiveMultiPartFormData() = clientTests {
test { client ->
val response = client.post("$TEST_SERVER/multipart/receive")

val multipart = response.body<MultiPartData>()
var textFound = false
var fileFound = false

multipart.forEachPart { part ->
when (part) {
is PartData.FormItem -> {
assertEquals("text", part.name)
assertEquals("Hello, World!", part.value)
textFound = true
}
is PartData.FileItem -> {
assertEquals("file", part.name)
assertEquals("test.bin", part.originalFileName)

val bytes = part.provider().readRemaining().readByteArray()
assertEquals(1024, bytes.size)
for (i in bytes.indices) {
assertEquals(i.toByte(), bytes[i])
}
fileFound = true
}
else -> fail("Unexpected part type: ${part::class.simpleName}")
}
part.dispose()
}

assertTrue(textFound, "Text part not found")
assertTrue(fileFound, "File part not found")
}
}
}
44 changes: 44 additions & 0 deletions ktor-http/ktor-http-cio/api/ktor-http-cio.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ final class io.ktor.http.cio/CIOHeaders : io.ktor.http/Headers { // io.ktor.http
final fun names(): kotlin.collections/Set<kotlin/String> // io.ktor.http.cio/CIOHeaders.names|names(){}[0]
}

final class io.ktor.http.cio/CIOMultipartDataBase : io.ktor.http.content/MultiPartData, kotlinx.coroutines/CoroutineScope { // io.ktor.http.cio/CIOMultipartDataBase|null[0]
constructor <init>(kotlin.coroutines/CoroutineContext, io.ktor.utils.io/ByteReadChannel, kotlin/CharSequence, kotlin/Long?, kotlin/Long = ...) // io.ktor.http.cio/CIOMultipartDataBase.<init>|<init>(kotlin.coroutines.CoroutineContext;io.ktor.utils.io.ByteReadChannel;kotlin.CharSequence;kotlin.Long?;kotlin.Long){}[0]

final val coroutineContext // io.ktor.http.cio/CIOMultipartDataBase.coroutineContext|{}coroutineContext[0]
final fun <get-coroutineContext>(): kotlin.coroutines/CoroutineContext // io.ktor.http.cio/CIOMultipartDataBase.coroutineContext.<get-coroutineContext>|<get-coroutineContext>(){}[0]

final suspend fun readPart(): io.ktor.http.content/PartData? // io.ktor.http.cio/CIOMultipartDataBase.readPart|readPart(){}[0]
}

final class io.ktor.http.cio/ConnectionOptions { // io.ktor.http.cio/ConnectionOptions|null[0]
constructor <init>(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin.collections/List<kotlin/String> = ...) // io.ktor.http.cio/ConnectionOptions.<init>|<init>(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.collections.List<kotlin.String>){}[0]

Expand Down Expand Up @@ -117,9 +126,44 @@ final class io.ktor.http.cio/Response : io.ktor.http.cio/HttpMessage { // io.kto
final fun <get-version>(): kotlin/CharSequence // io.ktor.http.cio/Response.version.<get-version>|<get-version>(){}[0]
}

sealed class io.ktor.http.cio/MultipartEvent { // io.ktor.http.cio/MultipartEvent|null[0]
abstract fun release() // io.ktor.http.cio/MultipartEvent.release|release(){}[0]

final class Epilogue : io.ktor.http.cio/MultipartEvent { // io.ktor.http.cio/MultipartEvent.Epilogue|null[0]
constructor <init>(kotlinx.io/Source) // io.ktor.http.cio/MultipartEvent.Epilogue.<init>|<init>(kotlinx.io.Source){}[0]

final val body // io.ktor.http.cio/MultipartEvent.Epilogue.body|{}body[0]
final fun <get-body>(): kotlinx.io/Source // io.ktor.http.cio/MultipartEvent.Epilogue.body.<get-body>|<get-body>(){}[0]

final fun release() // io.ktor.http.cio/MultipartEvent.Epilogue.release|release(){}[0]
}

final class MultipartPart : io.ktor.http.cio/MultipartEvent { // io.ktor.http.cio/MultipartEvent.MultipartPart|null[0]
constructor <init>(kotlinx.coroutines/Deferred<io.ktor.http.cio/HttpHeadersMap>, io.ktor.utils.io/ByteReadChannel) // io.ktor.http.cio/MultipartEvent.MultipartPart.<init>|<init>(kotlinx.coroutines.Deferred<io.ktor.http.cio.HttpHeadersMap>;io.ktor.utils.io.ByteReadChannel){}[0]

final val body // io.ktor.http.cio/MultipartEvent.MultipartPart.body|{}body[0]
final fun <get-body>(): io.ktor.utils.io/ByteReadChannel // io.ktor.http.cio/MultipartEvent.MultipartPart.body.<get-body>|<get-body>(){}[0]
final val headers // io.ktor.http.cio/MultipartEvent.MultipartPart.headers|{}headers[0]
final fun <get-headers>(): kotlinx.coroutines/Deferred<io.ktor.http.cio/HttpHeadersMap> // io.ktor.http.cio/MultipartEvent.MultipartPart.headers.<get-headers>|<get-headers>(){}[0]

final fun release() // io.ktor.http.cio/MultipartEvent.MultipartPart.release|release(){}[0]
}

final class Preamble : io.ktor.http.cio/MultipartEvent { // io.ktor.http.cio/MultipartEvent.Preamble|null[0]
constructor <init>(kotlinx.io/Source) // io.ktor.http.cio/MultipartEvent.Preamble.<init>|<init>(kotlinx.io.Source){}[0]

final val body // io.ktor.http.cio/MultipartEvent.Preamble.body|{}body[0]
final fun <get-body>(): kotlinx.io/Source // io.ktor.http.cio/MultipartEvent.Preamble.body.<get-body>|<get-body>(){}[0]

final fun release() // io.ktor.http.cio/MultipartEvent.Preamble.release|release(){}[0]
}
}

final fun (kotlin/CharSequence).io.ktor.http.cio.internals/parseDecLong(): kotlin/Long // io.ktor.http.cio.internals/parseDecLong|parseDecLong@kotlin.CharSequence(){}[0]
final fun (kotlinx.coroutines/CoroutineScope).io.ktor.http.cio/decodeChunked(io.ktor.utils.io/ByteReadChannel): io.ktor.utils.io/WriterJob // io.ktor.http.cio/decodeChunked|decodeChunked@kotlinx.coroutines.CoroutineScope(io.ktor.utils.io.ByteReadChannel){}[0]
final fun (kotlinx.coroutines/CoroutineScope).io.ktor.http.cio/decodeChunked(io.ktor.utils.io/ByteReadChannel, kotlin/Long): io.ktor.utils.io/WriterJob // io.ktor.http.cio/decodeChunked|decodeChunked@kotlinx.coroutines.CoroutineScope(io.ktor.utils.io.ByteReadChannel;kotlin.Long){}[0]
final fun (kotlinx.coroutines/CoroutineScope).io.ktor.http.cio/parseMultipart(io.ktor.utils.io/ByteReadChannel, io.ktor.http.cio/HttpHeadersMap, kotlin/Long = ...): kotlinx.coroutines.channels/ReceiveChannel<io.ktor.http.cio/MultipartEvent> // io.ktor.http.cio/parseMultipart|parseMultipart@kotlinx.coroutines.CoroutineScope(io.ktor.utils.io.ByteReadChannel;io.ktor.http.cio.HttpHeadersMap;kotlin.Long){}[0]
final fun (kotlinx.coroutines/CoroutineScope).io.ktor.http.cio/parseMultipart(io.ktor.utils.io/ByteReadChannel, kotlin/CharSequence, kotlin/Long?, kotlin/Long = ...): kotlinx.coroutines.channels/ReceiveChannel<io.ktor.http.cio/MultipartEvent> // io.ktor.http.cio/parseMultipart|parseMultipart@kotlinx.coroutines.CoroutineScope(io.ktor.utils.io.ByteReadChannel;kotlin.CharSequence;kotlin.Long?;kotlin.Long){}[0]
final fun io.ktor.http.cio/encodeChunked(io.ktor.utils.io/ByteWriteChannel, kotlin.coroutines/CoroutineContext): io.ktor.utils.io/ReaderJob // io.ktor.http.cio/encodeChunked|encodeChunked(io.ktor.utils.io.ByteWriteChannel;kotlin.coroutines.CoroutineContext){}[0]
final fun io.ktor.http.cio/expectHttpBody(io.ktor.http.cio/Request): kotlin/Boolean // io.ktor.http.cio/expectHttpBody|expectHttpBody(io.ktor.http.cio.Request){}[0]
final fun io.ktor.http.cio/expectHttpBody(io.ktor.http/HttpMethod, kotlin/Long, kotlin/CharSequence?, io.ktor.http.cio/ConnectionOptions?, kotlin/CharSequence?): kotlin/Boolean // io.ktor.http.cio/expectHttpBody|expectHttpBody(io.ktor.http.HttpMethod;kotlin.Long;kotlin.CharSequence?;io.ktor.http.cio.ConnectionOptions?;kotlin.CharSequence?){}[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public class CIOMultipartDataBase(
val event = events.receive()
eventToData(event)?.let { return it }
}
} catch (t: ClosedReceiveChannelException) {
} catch (_: ClosedReceiveChannelException) {
return null
}
}
Expand Down Expand Up @@ -77,13 +77,7 @@ public class CIOMultipartDataBase(

val body = part.body
if (filename == null) {
val packet = body.readRemaining() // formFieldLimit.toLong())
// if (!body.exhausted()) {
// val cause = IllegalStateException("Form field size limit exceeded: $formFieldLimit")
// body.cancel(cause)
// throw cause
// }

val packet = body.readRemaining()
packet.use {
return PartData.FormItem(it.readText(), { part.release() }, CIOHeaders(headers))
}
Expand Down
Loading

0 comments on commit 3a1b6c1

Please sign in to comment.