Skip to content

Commit 2fe4558

Browse files
rsinukov1e5l
authored andcommitted
KTOR-1689 Create client ContentNegotiation feature
1 parent d7275d0 commit 2fe4558

File tree

110 files changed

+1588
-1063
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

110 files changed

+1588
-1063
lines changed

build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ val disabledExplicitApiModeProjects = listOf(
117117
"ktor-client-json-tests",
118118
"ktor-server-test-host",
119119
"ktor-server-test-suites",
120-
"ktor-server-tests"
120+
"ktor-server-tests",
121+
"ktor-client-content-negotiation-tests",
121122
)
122123

123124
apply(from = "gradle/compatibility.gradle")

gradle/compatibility.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ apiValidation {
66
'ktor-client-tests',
77
'ktor-client-js',
88
'ktor-client-json-tests',
9+
'ktor-client-content-negotiation-tests',
910
'ktor-client',
1011
'ktor-client-features',
1112
'ktor-server-test-suites',
@@ -27,6 +28,7 @@ apiValidation {
2728
project(":ktor-io"),
2829
project(":ktor-server"),
2930
project(":ktor-features"),
31+
project(":ktor-shared"),
3032
]
3133

3234
while (!queue.isEmpty()) {

ktor-client/ktor-client-core/api/ktor-client-core.api

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -892,15 +892,15 @@ public final class io/ktor/client/request/HttpRequestBuilder : io/ktor/http/Http
892892
public final fun build ()Lio/ktor/client/request/HttpRequestData;
893893
public final fun getAttributes ()Lio/ktor/util/Attributes;
894894
public final fun getBody ()Ljava/lang/Object;
895-
public final fun getBodyType ()Lkotlin/reflect/KType;
895+
public final fun getBodyType ()Lio/ktor/util/reflect/TypeInfo;
896896
public final fun getCapabilityOrNull (Lio/ktor/client/engine/HttpClientEngineCapability;)Ljava/lang/Object;
897897
public final fun getExecutionContext ()Lkotlinx/coroutines/Job;
898898
public fun getHeaders ()Lio/ktor/http/HeadersBuilder;
899899
public final fun getMethod ()Lio/ktor/http/HttpMethod;
900900
public final fun getUrl ()Lio/ktor/http/URLBuilder;
901901
public final fun setAttributes (Lkotlin/jvm/functions/Function1;)V
902902
public final fun setBody (Ljava/lang/Object;)V
903-
public final fun setBodyType (Lkotlin/reflect/KType;)V
903+
public final fun setBodyType (Lio/ktor/util/reflect/TypeInfo;)V
904904
public final fun setCapability (Lio/ktor/client/engine/HttpClientEngineCapability;Ljava/lang/Object;)V
905905
public final fun setMethod (Lio/ktor/http/HttpMethod;)V
906906
public final fun takeFrom (Lio/ktor/client/request/HttpRequestBuilder;)Lio/ktor/client/request/HttpRequestBuilder;

ktor-client/ktor-client-core/common/src/io/ktor/client/call/TypeInfo.kt

Lines changed: 0 additions & 64 deletions
This file was deleted.

ktor-client/ktor-client-core/common/src/io/ktor/client/features/HttpSend.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public class HttpSend(
8080
check(content is OutgoingContent) {
8181
"""
8282
|Fail to serialize body. Content has type: ${content::class}, but OutgoingContent expected.
83-
|If you expect serialized body, please check that you have installed the corresponding feature(like `Json`) and set `Content-Type` header."""
83+
|If you expect serialized body, please check that you have installed the corresponding feature(like `ContentNegotiation`) and set `Content-Type` header."""
8484
.trimMargin()
8585
}
8686
context.setBody(content)

ktor-client/ktor-client-core/common/src/io/ktor/client/request/HttpRequest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.ktor.http.*
1212
import io.ktor.http.content.*
1313
import io.ktor.util.*
1414
import io.ktor.util.date.*
15+
import io.ktor.util.reflect.*
1516
import io.ktor.utils.io.*
1617
import kotlinx.coroutines.*
1718
import kotlin.coroutines.*
@@ -78,7 +79,7 @@ public class HttpRequestBuilder : HttpMessageBuilder {
7879
/**
7980
* The [KType] of [body] for this request. Null for default types that don't need serialization.
8081
*/
81-
public var bodyType: KType?
82+
public var bodyType: TypeInfo?
8283
get() = attributes.getOrNull(BodyTypeAttributeKey)
8384
@InternalAPI set(value) {
8485
if (value != null) {
@@ -144,7 +145,7 @@ public class HttpRequestBuilder : HttpMessageBuilder {
144145
body = builder.body
145146
bodyType = builder.bodyType
146147
url.takeFrom(builder.url)
147-
url.encodedPath = if (url.encodedPath.isBlank()) "/" else url.encodedPath
148+
url.encodedPath = url.encodedPath.ifBlank { "/" }
148149
headers.appendAll(builder.headers)
149150
attributes.putAll(builder.attributes)
150151

ktor-client/ktor-client-core/common/src/io/ktor/client/request/RequestBody.kt

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,25 @@ package io.ktor.client.request
77
import io.ktor.client.utils.*
88
import io.ktor.http.content.*
99
import io.ktor.util.*
10-
import io.ktor.utils.io.*
10+
import io.ktor.util.reflect.*
1111
import kotlin.native.concurrent.*
12-
import kotlin.reflect.*
1312

1413
@SharedImmutable
15-
internal val BodyTypeAttributeKey: AttributeKey<KType> = AttributeKey("BodyTypeAttributeKey")
16-
17-
@PublishedApi
18-
@OptIn(ExperimentalStdlibApi::class)
19-
internal inline fun <reified T : Any> tryGetType(ignored: T): KType? = try {
20-
// We need to wrap getting type in try catch because of KT-42913
21-
typeOf<T>()
22-
} catch (_: Throwable) {
23-
null
24-
}
14+
internal val BodyTypeAttributeKey: AttributeKey<TypeInfo> = AttributeKey("BodyTypeAttributeKey")
2515

2616
public inline fun <reified T> HttpRequestBuilder.setBody(body: T) {
2717
when (body) {
2818
null -> {
2919
this.body = EmptyContent
20+
bodyType = null
3021
}
31-
is String,
32-
is OutgoingContent,
33-
is ByteArray,
34-
is ByteReadChannel -> {
22+
is OutgoingContent -> {
3523
this.body = body
24+
bodyType = null
3625
}
3726
else -> {
3827
this.body = body
39-
bodyType = tryGetType(body)
28+
bodyType = typeInfo<T>()
4029
}
4130
}
4231
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
public final class io/ktor/client/features/ContentConverterException : java/lang/Exception {
2+
public fun <init> (Ljava/lang/String;)V
3+
}
4+
5+
public final class io/ktor/client/features/ContentNegotiation {
6+
public static final field Feature Lio/ktor/client/features/ContentNegotiation$Feature;
7+
}
8+
9+
public final class io/ktor/client/features/ContentNegotiation$Config : io/ktor/shared/serialization/Configuration {
10+
public fun <init> ()V
11+
public final fun register (Lio/ktor/http/ContentType;Lio/ktor/shared/serialization/ContentConverter;Lio/ktor/http/ContentTypeMatcher;Lkotlin/jvm/functions/Function1;)V
12+
public fun register (Lio/ktor/http/ContentType;Lio/ktor/shared/serialization/ContentConverter;Lkotlin/jvm/functions/Function1;)V
13+
}
14+
15+
public final class io/ktor/client/features/ContentNegotiation$Feature : io/ktor/client/features/HttpClientFeature {
16+
public fun getKey ()Lio/ktor/util/AttributeKey;
17+
public fun install (Lio/ktor/client/features/ContentNegotiation;Lio/ktor/client/HttpClient;)V
18+
public synthetic fun install (Ljava/lang/Object;Lio/ktor/client/HttpClient;)V
19+
public fun prepare (Lkotlin/jvm/functions/Function1;)Lio/ktor/client/features/ContentNegotiation;
20+
public synthetic fun prepare (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
21+
}
22+
23+
public final class io/ktor/client/features/JsonContentTypeMatcher : io/ktor/http/ContentTypeMatcher {
24+
public static final field INSTANCE Lio/ktor/client/features/JsonContentTypeMatcher;
25+
public fun contains (Lio/ktor/http/ContentType;)Z
26+
}
27+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2014-2020 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
description = "Ktor client Content Negotiation support"
6+
7+
val ideaActive: Boolean by project.extra
8+
9+
plugins {
10+
id("kotlinx-serialization")
11+
}
12+
13+
kotlin {
14+
sourceSets {
15+
commonMain {
16+
dependencies {
17+
api(project(":ktor-shared:ktor-shared-serialization"))
18+
}
19+
}
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.client.features
6+
7+
import io.ktor.client.*
8+
import io.ktor.client.request.*
9+
import io.ktor.client.statement.*
10+
import io.ktor.client.utils.*
11+
import io.ktor.http.*
12+
import io.ktor.shared.serialization.*
13+
import io.ktor.util.*
14+
import io.ktor.utils.io.*
15+
import io.ktor.utils.io.charsets.*
16+
17+
/**
18+
* [HttpClient] feature that serializes/de-serializes custom objects
19+
* to request and from response bodies using a [ContentConverter]
20+
* to Content-Type and Accept headers.
21+
*/
22+
public class ContentNegotiation internal constructor(
23+
internal val registrations: List<Config.ConverterRegistration>
24+
) {
25+
26+
/**
27+
* [ContentNegotiation] configuration that is used during installation
28+
*/
29+
public class Config : Configuration {
30+
31+
internal class ConverterRegistration(
32+
val converter: ContentConverter,
33+
val contentTypeToSend: ContentType,
34+
val contentTypeMatcher: ContentTypeMatcher,
35+
)
36+
37+
internal val registrations = mutableListOf<ConverterRegistration>()
38+
39+
/**
40+
* Registers a [contentType] to a specified [converter] with an optional [configuration] script for converter
41+
*/
42+
public override fun <T : ContentConverter> register(
43+
contentType: ContentType,
44+
converter: T,
45+
configuration: T.() -> Unit
46+
) {
47+
val matcher = when (contentType) {
48+
ContentType.Application.Json -> JsonContentTypeMatcher
49+
else -> defaultMatcher(contentType)
50+
}
51+
register(contentType, converter, matcher, configuration)
52+
}
53+
54+
/**
55+
* Registers a [contentTypeToSend] and [contentTypeMatcher] to a specified [converter] with
56+
* an optional [configuration] script for converter
57+
*/
58+
public fun <T : ContentConverter> register(
59+
contentTypeToSend: ContentType,
60+
converter: T,
61+
contentTypeMatcher: ContentTypeMatcher,
62+
configuration: T.() -> Unit
63+
) {
64+
val registration = ConverterRegistration(
65+
converter.apply(configuration),
66+
contentTypeToSend,
67+
contentTypeMatcher
68+
)
69+
registrations.add(registration)
70+
}
71+
72+
private fun defaultMatcher(pattern: ContentType): ContentTypeMatcher = object : ContentTypeMatcher {
73+
override fun contains(contentType: ContentType): Boolean = contentType.match(pattern)
74+
}
75+
}
76+
77+
/**
78+
* Companion object for feature installation
79+
*/
80+
public companion object Feature : HttpClientFeature<Config, ContentNegotiation> {
81+
public override val key: AttributeKey<ContentNegotiation> = AttributeKey("ContentNegotiation")
82+
83+
override fun prepare(block: Config.() -> Unit): ContentNegotiation {
84+
val config = Config().apply(block)
85+
return ContentNegotiation(config.registrations)
86+
}
87+
88+
override fun install(feature: ContentNegotiation, scope: HttpClient) {
89+
scope.requestPipeline.intercept(HttpRequestPipeline.Transform) { payload ->
90+
val registrations = feature.registrations
91+
registrations.forEach { context.accept(it.contentTypeToSend) }
92+
93+
val contentType = context.contentType() ?: return@intercept
94+
context.headers.remove(HttpHeaders.ContentType)
95+
96+
if (payload is Unit || payload is EmptyContent) {
97+
proceedWith(EmptyContent)
98+
return@intercept
99+
}
100+
val registration = registrations
101+
.firstOrNull { it.contentTypeMatcher.contains(contentType) } ?: return@intercept
102+
103+
val serializedContent = registration.converter.serialize(
104+
contentType,
105+
contentType.charset() ?: Charsets.UTF_8,
106+
context.bodyType!!,
107+
payload
108+
) ?: throw ContentConverterException(
109+
"Can't convert $payload with contentType $contentType using converter ${registration.converter}"
110+
)
111+
112+
proceedWith(serializedContent)
113+
}
114+
115+
scope.responsePipeline.intercept(HttpResponsePipeline.Transform) { (info, body) ->
116+
if (body !is ByteReadChannel) return@intercept
117+
118+
val contentType = context.response.contentType() ?: return@intercept
119+
val registrations = feature.registrations
120+
val registration = registrations
121+
.firstOrNull { it.contentTypeMatcher.contains(contentType) } ?: return@intercept
122+
123+
val parsedBody = registration.converter
124+
.deserialize(context.request.headers.suitableCharset(), info, body) ?: return@intercept
125+
val response = HttpResponseContainer(info, parsedBody)
126+
proceedWith(response)
127+
}
128+
}
129+
}
130+
}
131+
132+
public class ContentConverterException(message: String) : Exception(message)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2014-2020 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.client.features
6+
7+
import io.ktor.http.*
8+
9+
/**
10+
* Matcher that accepts all extended json content types
11+
*/
12+
public object JsonContentTypeMatcher : ContentTypeMatcher {
13+
override fun contains(contentType: ContentType): Boolean {
14+
if (contentType.match(ContentType.Application.Json)) {
15+
return true
16+
}
17+
18+
val value = contentType.withoutParameters().toString()
19+
return value.startsWith("application/") && value.endsWith("+json")
20+
}
21+
}

0 commit comments

Comments
 (0)