Skip to content

Commit

Permalink
KTOR-745 Support null json body (#3028)
Browse files Browse the repository at this point in the history
  • Loading branch information
rsinukov committed Jun 30, 2022
1 parent 3d5f1d4 commit bad1c2d
Show file tree
Hide file tree
Showing 43 changed files with 435 additions and 100 deletions.
1 change: 1 addition & 0 deletions ktor-client/ktor-client-core/api/ktor-client-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public class io/ktor/client/call/HttpClientCall : kotlinx/coroutines/CoroutineSc
public fun <init> (Lio/ktor/client/HttpClient;)V
public fun <init> (Lio/ktor/client/HttpClient;Lio/ktor/client/request/HttpRequestData;Lio/ktor/client/request/HttpResponseData;)V
public final fun body (Lio/ktor/util/reflect/TypeInfo;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun bodyNullable (Lio/ktor/util/reflect/TypeInfo;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
protected fun getAllowDoubleReceive ()Z
public final fun getAttributes ()Lio/ktor/util/Attributes;
public final fun getClient ()Lio/ktor/client/HttpClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package io.ktor.client.call
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.content.*
import io.ktor.util.*
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
Expand Down Expand Up @@ -73,7 +74,7 @@ public open class HttpClientCall(
* @throws DoubleReceiveException If already called [body].
*/
@OptIn(InternalAPI::class)
public suspend fun body(info: TypeInfo): Any {
public suspend fun bodyNullable(info: TypeInfo): Any? {
try {
if (response.instanceOf(info.type)) return response
if (!allowDoubleReceive && !received.compareAndSet(false, true)) {
Expand All @@ -84,9 +85,9 @@ public open class HttpClientCall(
val responseData = attributes.getOrNull(CustomResponse) ?: getResponseContent()

val subject = HttpResponseContainer(info, responseData)
val result = client.responsePipeline.execute(this, subject).response
val result = client.responsePipeline.execute(this, subject).response.takeIf { it != NullBody }

if (!result.instanceOf(info.type)) {
if (result != null && !result.instanceOf(info.type)) {
val from = result::class
val to = info.type
throw NoTransformationFoundException(response, from, to)
Expand All @@ -101,6 +102,17 @@ public open class HttpClientCall(
}
}

/**
* Tries to receive the payload of the [response] as a specific expected type provided in [info].
* Returns [response] if [info] corresponds to [HttpResponse].
*
* @throws NoTransformationFoundException If no transformation is found for the type [info].
* @throws DoubleReceiveException If already called [body].
* @throws NullPointerException If content is `null`.
*/
@OptIn(InternalAPI::class)
public suspend fun body(info: TypeInfo): Any = bodyNullable(info)!!

override fun toString(): String = "HttpClientCall[${request.url}, ${response.status}]"

internal fun setResponse(response: HttpResponse) {
Expand Down Expand Up @@ -132,15 +144,15 @@ public open class HttpClientCall(
* @throws NoTransformationFoundException If no transformation is found for the type [T].
* @throws DoubleReceiveException If already called [body].
*/
public suspend inline fun <reified T> HttpClientCall.body(): T = body(typeInfo<T>()) as T
public suspend inline fun <reified T> HttpClientCall.body(): T = bodyNullable(typeInfo<T>()) as T

/**
* Tries to receive the payload of the [response] as a specific type [T].
*
* @throws NoTransformationFoundException If no transformation is found for the type [T].
* @throws DoubleReceiveException If already called [body].
*/
public suspend inline fun <reified T> HttpResponse.body(): T = call.body(typeInfo<T>()) as T
public suspend inline fun <reified T> HttpResponse.body(): T = call.bodyNullable(typeInfo<T>()) as T

/**
* Tries to receive the payload of the [response] as a specific type [T] described in [typeInfo].
Expand All @@ -149,7 +161,7 @@ public suspend inline fun <reified T> HttpResponse.body(): T = call.body(typeInf
* @throws DoubleReceiveException If already called [body].
*/
@Suppress("UNCHECKED_CAST")
public suspend fun <T> HttpResponse.body(typeInfo: TypeInfo): T = call.body(typeInfo) as T
public suspend fun <T> HttpResponse.body(typeInfo: TypeInfo): T = call.bodyNullable(typeInfo) as T

/**
* Exception representing that the response payload has already been received.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public val DefaultClientWebSocketSession.converter: WebsocketContentConverter?
*
* @throws WebsocketConverterNotFoundException if no [contentConverter] is found for the [WebSockets] plugin
*/
public suspend inline fun <reified T : Any> DefaultClientWebSocketSession.sendSerialized(data: T) {
public suspend inline fun <reified T> DefaultClientWebSocketSession.sendSerialized(data: T) {
val converter = converter
?: throw WebsocketConverterNotFoundException("No converter was found for websocket")

Expand All @@ -71,12 +71,12 @@ public suspend inline fun <reified T : Any> DefaultClientWebSocketSession.sendSe
* @throws WebsocketConverterNotFoundException if no [contentConverter] is found for the [WebSockets] plugin
* @throws WebsocketDeserializeException if the received frame can't be deserialized to type [T]
*/
public suspend inline fun <reified T : Any> DefaultClientWebSocketSession.receiveDeserialized(): T {
public suspend inline fun <reified T> DefaultClientWebSocketSession.receiveDeserialized(): T {
val converter = converter
?: throw WebsocketConverterNotFoundException("No converter was found for websocket")

return receiveDeserializedBase(
return receiveDeserializedBase<T>(
converter,
call.request.headers.suitableCharset()
)
) as T
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

package io.ktor.client.request

import io.ktor.client.utils.*
import io.ktor.http.content.*
import io.ktor.util.*
import io.ktor.util.reflect.*
Expand All @@ -15,8 +14,8 @@ internal val BodyTypeAttributeKey: AttributeKey<TypeInfo> = AttributeKey("BodyTy
public inline fun <reified T> HttpRequestBuilder.setBody(body: T) {
when (body) {
null -> {
this.body = EmptyContent
bodyType = null
this.body = NullBody
bodyType = typeInfo<T>()
}
is OutgoingContent -> {
this.body = body
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ package io.ktor.client.engine.darwin.internal

import io.ktor.client.engine.darwin.*
import io.ktor.client.request.*
import io.ktor.client.utils.*
import io.ktor.http.*
import io.ktor.util.collections.*
import io.ktor.util.date.*
import io.ktor.utils.io.*
import kotlinx.cinterop.*
import kotlinx.coroutines.*
import platform.Foundation.*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public class ContentNegotiation internal constructor(
contentType,
contentType.charset() ?: Charsets.UTF_8,
context.bodyType!!,
payload
payload.takeIf { it != NullBody }
)
} ?: throw ContentConverterException(
"Can't convert $payload with contentType $contentType using converters " +
Expand All @@ -137,15 +137,13 @@ public class ContentNegotiation internal constructor(

val contentType = context.response.contentType() ?: return@intercept
val registrations = plugin.registrations
val matchingRegistrations = registrations
val suitableConverters = registrations
.filter { it.contentTypeMatcher.contains(contentType) }
.map { it.converter }
.takeIf { it.isNotEmpty() } ?: return@intercept

// Pick the first one that can convert the subject successfully
val parsedBody = matchingRegistrations.firstNotNullOfOrNull { registration ->
registration.converter
.deserialize(context.request.headers.suitableCharset(), info, body)
} ?: return@intercept
@OptIn(InternalAPI::class)
val parsedBody = suitableConverters.deserialize(body, info, context.request.headers.suitableCharset())
val response = HttpResponseContainer(info, parsedBody)
proceedWith(response)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import io.ktor.utils.io.charsets.*

typealias ContentConverterSerialize = suspend (ContentType, Charset, TypeInfo, Any) -> OutgoingContent?
typealias ContentConverterSerialize = suspend (ContentType, Charset, TypeInfo, Any?) -> OutgoingContent?
typealias ContentConverterDeserialize = suspend (Charset, TypeInfo, ByteReadChannel) -> Any?

class TestContentConverter(
Expand All @@ -23,7 +23,7 @@ class TestContentConverter(
contentType: ContentType,
charset: Charset,
typeInfo: TypeInfo,
value: Any
value: Any?
): OutgoingContent? = serializeFn(contentType, charset, typeInfo, value)

override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ abstract class AbstractClientContentNegotiationTest : TestWithKtor() {

call.respondWithRequestBody(defaultContentType)
}
post("/null") {
assertEquals("null", call.receiveText())
call.respondText("null", defaultContentType)
}
webSocket("ws") {
for (frame in incoming) {
outgoing.send(frame)
Expand Down Expand Up @@ -156,6 +160,22 @@ abstract class AbstractClientContentNegotiationTest : TestWithKtor() {
}
}

@Test
open fun testSerializeNull(): Unit = testWithEngine(CIO) {
configureClient()

test { client ->
val data: Widget? = null
val result = client.post {
url(path = "/null", port = serverPort)
contentType(ContentType.Application.Json)
setBody(data)
}.body<Widget?>()

assertEquals(null, result)
}
}

@Test
open fun testSerializeNested(): Unit = testWithEngine(CIO) {
configureClient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import kotlinx.serialization.*
import kotlin.test.*

@Suppress("DEPRECATION")
abstract class JsonContentNegotiationTest(private val converter: ContentConverter) {
protected open val extraFieldResult = HttpStatusCode.OK

@Serializable
data class Wrapper(val value: String)

fun startServer(testApplicationEngine: Application) {
Expand Down Expand Up @@ -159,4 +161,48 @@ abstract class JsonContentNegotiationTest(private val converter: ContentConverte
assertEquals("OK", response.bodyAsText())
}
}

@Test
open fun testJsonNullServer(): Unit = testApplication {
install(ContentNegotiation) {
register(ContentType.Application.Json, converter)
}
routing {
post("/") {
val request = call.receiveNullable<Wrapper?>()
assertEquals(null, request)
call.respondNullable(request)
}
}

client.post("/") {
contentType(ContentType.Application.Json)
setBody("null")
}.let { response ->
assertEquals("null", response.bodyAsText())
}
}

@Test
open fun testJsonNullClient(): Unit = testApplication {
routing {
post("/") {
val request = call.receive<String>()
assertEquals("null", request)
call.respond(TextContent("null", ContentType.Application.Json))
}
}

createClient {
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) {
register(ContentType.Application.Json, converter)
}
}.post("/") {
val data: Wrapper? = null
contentType(ContentType.Application.Json)
setBody(data)
}.let { response ->
assertEquals(null, response.body<Wrapper?>())
}
}
}
Loading

0 comments on commit bad1c2d

Please sign in to comment.