Skip to content

Commit f54e587

Browse files
yt8492BoD
andauthored
Support Ktor Engine (#5142)
* add common KtorHttpEngine * add ktor engine dependencies * add KtorHttpEngine test * upgradle ktor version to 2.3.2 * upgradle kotlinx-coroutines version to 1.7.3 * rename KtorEngine.kt to KtorHttpEngine.kt * add KtorWebSocketEngine * add KtorExtensions * rename parameter * move ktor engine implements to apollo-engine-ktor module * use latest ktor version * generate apollo-engine-ktor api * add apollo-engine-ktor module def in libraries.toml * add integration tests for ktor engine * Minor tweaks in build.gradle.kts and gradle.properties * Mark new APIs as @ApolloExperimental * Duplicate a WebSocket tests to use the Ktor engine, and tweak KtorWebSocketEngine to resolve issues --------- Co-authored-by: BoD <[email protected]>
1 parent c2bf4fc commit f54e587

File tree

19 files changed

+661
-44
lines changed

19 files changed

+661
-44
lines changed

gradle/libraries.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ kotlin-plugin-min = "1.6.0"
3030
kotlin-plugin = "1.9.0"
3131
kotlin-plugin-max = "1.9.0"
3232
kotlin-stdlib = "1.6.21"
33-
kotlinx-coroutines = "1.6.4"
33+
kotlinx-coroutines = "1.7.3"
3434
kotlinx-datetime = "0.4.0"
3535
kotlinx-serialization-runtime = "1.5.0"
3636
ksp = "1.9.0-1.0.11"
37-
ktor = "2.2.2"
37+
ktor = "2.3.3"
3838
okhttp = "4.11.0"
3939
rx-android = "2.0.1"
4040
rx-java2 = "2.2.21"
@@ -77,6 +77,7 @@ apollo-runtime = { group = "com.apollographql.apollo3", name = "apollo-runtime",
7777
apollo-runtime-published = { group = "com.apollographql.apollo3", name = "apollo-runtime", version.ref = "apollo-published" }
7878
apollo-annotations = { group = "com.apollographql.apollo3", name = "apollo-annotations", version.ref = "apollo" }
7979
apollo-runtime-java = { group = "com.apollographql.apollo3", name = "apollo-runtime-java", version.ref = "apollo" }
80+
apollo-engine-ktor = { group = "com.apollographql.apollo3", name = "apollo-engine-ktor", version.ref = "apollo" }
8081
apollo-rx2 = { group = "com.apollographql.apollo3", name = "apollo-rx2-support", version.ref = "apollo" }
8182
apollo-rx3-java = { group = "com.apollographql.apollo3", name = "apollo-rx3-support-java", version.ref = "apollo" }
8283
apollo-testingsupport = { group = "com.apollographql.apollo3", name = "apollo-testing-support", version.ref = "apollo" }
@@ -144,7 +145,11 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
144145
kotlinx-serialization-json-okio = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json-okio", version.ref = "kotlinx-serialization-runtime" }
145146
kotlinx-binarycompatibilityvalidator = { group = "org.jetbrains.kotlinx", name = "binary-compatibility-validator", version = "0.13.2" }
146147
ksp = { group = "com.google.devtools.ksp", name = "symbol-processing-gradle-plugin", version.ref = "ksp" }
148+
ktor-client-core = { group = "io.ktor", name= "ktor-client-core", version.ref = "ktor" }
149+
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
150+
ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" }
147151
ktor-client-js = { group = "io.ktor", name = "ktor-client-js", version.ref = "ktor" }
152+
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
148153
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
149154
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
150155
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
public final class com/apollographql/apollo3/network/KtorExtensionsKt {
2+
}
3+
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
plugins {
2+
id("org.jetbrains.kotlin.multiplatform")
3+
id("apollo.library")
4+
}
5+
6+
apolloLibrary {
7+
javaModuleName("com.apollographql.apollo3.engine.ktor")
8+
mpp {
9+
withLinux.set(false)
10+
}
11+
}
12+
13+
kotlin {
14+
sourceSets {
15+
findByName("commonMain")?.apply {
16+
dependencies {
17+
api(project(":apollo-runtime"))
18+
api(libs.ktor.client.core)
19+
api(libs.ktor.client.websockets)
20+
}
21+
}
22+
23+
findByName("commonTest")?.apply {
24+
dependencies {
25+
implementation(project(":apollo-mockserver"))
26+
implementation(project(":apollo-testing-support")) {
27+
because("runTest")
28+
// We have a circular dependency here that creates a warning in JS
29+
// w: duplicate library name: com.apollographql.apollo3:apollo-mockserver
30+
// See https://youtrack.jetbrains.com/issue/KT-51110
31+
// We should probably remove this circular dependency but for the time being, just use excludes
32+
exclude(group = "com.apollographql.apollo3", module = "apollo-runtime")
33+
}
34+
}
35+
}
36+
37+
findByName("jvmMain")?.apply {
38+
dependencies {
39+
api(libs.ktor.client.okhttp)
40+
}
41+
}
42+
43+
findByName("appleMain")?.apply {
44+
dependencies {
45+
api(libs.ktor.client.darwin)
46+
}
47+
}
48+
}
49+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
POM_ARTIFACT_ID=apollo-engine-ktor
2+
POM_NAME=Apollo Engine Ktor
3+
POM_DESCRIPTION=Http and WebSocket engines for Apollo Kotlin based on Ktor
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.apollographql.apollo3.network
2+
3+
import com.apollographql.apollo3.ApolloClient
4+
import com.apollographql.apollo3.annotations.ApolloExperimental
5+
import com.apollographql.apollo3.network.http.KtorHttpEngine
6+
import com.apollographql.apollo3.network.ws.KtorWebSocketEngine
7+
import io.ktor.client.HttpClient
8+
9+
/**
10+
* Configures the [ApolloClient] to use the Ktor [HttpClient] for network requests.
11+
* The [HttpClient] will be used for both HTTP and WebSocket requests.
12+
*
13+
* See also [ApolloClient.Builder.httpEngine] and [ApolloClient.Builder.webSocketEngine]
14+
*/
15+
@ApolloExperimental
16+
fun ApolloClient.Builder.ktorClient(httpClient: HttpClient) = apply {
17+
httpEngine(KtorHttpEngine(httpClient))
18+
webSocketEngine(KtorWebSocketEngine(httpClient))
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.apollographql.apollo3.network.http
2+
3+
import com.apollographql.apollo3.annotations.ApolloExperimental
4+
import com.apollographql.apollo3.api.http.HttpHeader
5+
import com.apollographql.apollo3.api.http.HttpMethod
6+
import com.apollographql.apollo3.api.http.HttpRequest
7+
import com.apollographql.apollo3.api.http.HttpResponse
8+
import com.apollographql.apollo3.exception.ApolloNetworkException
9+
import io.ktor.client.HttpClient
10+
import io.ktor.client.call.body
11+
import io.ktor.client.plugins.HttpTimeout
12+
import io.ktor.client.request.header
13+
import io.ktor.client.request.request
14+
import io.ktor.client.request.setBody
15+
import io.ktor.http.HttpHeaders
16+
import io.ktor.util.flattenEntries
17+
import okio.Buffer
18+
import kotlin.coroutines.cancellation.CancellationException
19+
20+
@ApolloExperimental
21+
class KtorHttpEngine(
22+
private val client: HttpClient,
23+
): HttpEngine {
24+
25+
private var disposed = false
26+
27+
constructor(timeoutMillis: Long = 60_000) : this(timeoutMillis, timeoutMillis)
28+
29+
constructor(connectTimeoutMillis: Long, requestTimeoutMillis: Long) : this(
30+
HttpClient {
31+
expectSuccess = false
32+
install(HttpTimeout) {
33+
this.connectTimeoutMillis = connectTimeoutMillis
34+
this.requestTimeoutMillis = requestTimeoutMillis
35+
}
36+
}
37+
)
38+
39+
override suspend fun execute(request: HttpRequest): HttpResponse {
40+
try {
41+
val response = client.request(request.url) {
42+
method = when (request.method) {
43+
HttpMethod.Get -> io.ktor.http.HttpMethod.Get
44+
HttpMethod.Post -> io.ktor.http.HttpMethod.Post
45+
}
46+
request.headers.forEach {
47+
header(it.name, it.value)
48+
}
49+
request.body?.let {
50+
header(HttpHeaders.ContentType, it.contentType)
51+
val buffer = Buffer()
52+
it.writeTo(buffer)
53+
setBody(buffer.readUtf8())
54+
}
55+
}
56+
val responseByteArray: ByteArray = response.body()
57+
val responseBufferedSource = Buffer().write(responseByteArray)
58+
return HttpResponse.Builder(statusCode = response.status.value)
59+
.body(responseBufferedSource)
60+
.addHeaders(response.headers.flattenEntries().map { HttpHeader(it.first, it.second) })
61+
.build()
62+
} catch (e: CancellationException) {
63+
// Cancellation Exception is passthrough
64+
throw e
65+
} catch (t: Throwable) {
66+
throw ApolloNetworkException(t.message, t)
67+
}
68+
}
69+
70+
override fun dispose() {
71+
if (!disposed) {
72+
client.close()
73+
disposed = true
74+
}
75+
}
76+
}
77+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.apollographql.apollo3.network.ws
2+
3+
import com.apollographql.apollo3.annotations.ApolloExperimental
4+
import com.apollographql.apollo3.api.http.HttpHeader
5+
import com.apollographql.apollo3.exception.ApolloWebSocketClosedException
6+
import io.ktor.client.HttpClient
7+
import io.ktor.client.plugins.websocket.WebSockets
8+
import io.ktor.client.plugins.websocket.webSocket
9+
import io.ktor.client.request.headers
10+
import io.ktor.client.request.url
11+
import io.ktor.http.URLBuilder
12+
import io.ktor.http.URLProtocol
13+
import io.ktor.http.Url
14+
import io.ktor.websocket.Frame
15+
import io.ktor.websocket.close
16+
import io.ktor.websocket.readText
17+
import kotlinx.coroutines.CoroutineScope
18+
import kotlinx.coroutines.Dispatchers
19+
import kotlinx.coroutines.SupervisorJob
20+
import kotlinx.coroutines.channels.Channel
21+
import kotlinx.coroutines.channels.ClosedReceiveChannelException
22+
import kotlinx.coroutines.launch
23+
import okio.ByteString
24+
25+
@ApolloExperimental
26+
class KtorWebSocketEngine(
27+
private val client: HttpClient,
28+
) : WebSocketEngine {
29+
30+
constructor() : this(
31+
HttpClient {
32+
install(WebSockets)
33+
}
34+
)
35+
36+
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
37+
38+
override suspend fun open(
39+
url: String,
40+
headers: List<HttpHeader>,
41+
): WebSocketConnection = open(Url(url), headers)
42+
43+
private suspend fun open(url: Url, headers: List<HttpHeader>): WebSocketConnection {
44+
val newUrl = URLBuilder(url).apply {
45+
protocol = when (url.protocol) {
46+
URLProtocol.HTTPS -> URLProtocol.WSS
47+
URLProtocol.HTTP -> URLProtocol.WS
48+
URLProtocol.WS, URLProtocol.WSS -> url.protocol
49+
/* URLProtocol.SOCKS */else -> throw UnsupportedOperationException("SOCKS is not a supported protocol")
50+
}
51+
}.build()
52+
val receiveMessageChannel = Channel<String>(Channel.UNLIMITED)
53+
val sendFrameChannel = Channel<Frame>(Channel.UNLIMITED)
54+
coroutineScope.launch {
55+
try {
56+
client.webSocket(
57+
request = {
58+
headers {
59+
headers.forEach {
60+
append(it.name, it.value)
61+
}
62+
}
63+
url(newUrl)
64+
},
65+
) {
66+
launch {
67+
while (true) {
68+
val frame = sendFrameChannel.receive()
69+
try {
70+
send(frame)
71+
} catch (e: Exception) {
72+
val closeReason = try {closeReason.await()} catch (e: Exception) {null}
73+
receiveMessageChannel.close(ApolloWebSocketClosedException(code = closeReason?.code?.toInt()
74+
?: -1, reason = closeReason?.message, cause = e))
75+
sendFrameChannel.close(e)
76+
break
77+
}
78+
}
79+
}
80+
while (true) {
81+
when (val frame = try {
82+
incoming.receive()
83+
} catch (e: ClosedReceiveChannelException) {
84+
val closeReason = try {closeReason.await()} catch (e: Exception) {null}
85+
receiveMessageChannel.close(ApolloWebSocketClosedException(code = closeReason?.code?.toInt()
86+
?: -1, reason = closeReason?.message, cause = e))
87+
sendFrameChannel.close(e)
88+
break
89+
}) {
90+
is Frame.Text -> {
91+
receiveMessageChannel.send(frame.readText())
92+
}
93+
94+
is Frame.Binary -> {
95+
receiveMessageChannel.send(frame.data.decodeToString())
96+
}
97+
98+
is Frame.Ping -> {
99+
send(Frame.Pong(frame.data))
100+
}
101+
102+
is Frame.Pong -> {}
103+
is Frame.Close -> {
104+
close()
105+
receiveMessageChannel.close()
106+
}
107+
108+
else -> error("unknown frame type")
109+
}
110+
}
111+
}
112+
} catch (e: Exception) {
113+
receiveMessageChannel.close(e)
114+
sendFrameChannel.close(e)
115+
}
116+
}
117+
return object : WebSocketConnection {
118+
override suspend fun receive(): String {
119+
return receiveMessageChannel.receive()
120+
}
121+
122+
override fun send(data: ByteString) {
123+
sendFrameChannel.trySend(Frame.Binary(true, data.toByteArray()))
124+
}
125+
126+
override fun send(string: String) {
127+
sendFrameChannel.trySend(Frame.Text(string))
128+
}
129+
130+
override fun close() {
131+
sendFrameChannel.trySend(Frame.Close())
132+
sendFrameChannel.close()
133+
}
134+
135+
}
136+
}
137+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import com.apollographql.apollo3.mockserver.MockResponse
2+
import com.apollographql.apollo3.mockserver.MockServer
3+
import com.apollographql.apollo3.network.http.KtorHttpEngine
4+
import com.apollographql.apollo3.network.http.get
5+
import com.apollographql.apollo3.testing.internal.runTest
6+
import okio.Buffer
7+
import okio.ByteString
8+
import kotlin.test.Test
9+
import kotlin.test.assertEquals
10+
11+
class KtorHttpEngineTest {
12+
// "Hello World" gzipped and hex encoded
13+
val gzipData = """
14+
1f8b 0800 0000 0000 0003 f348 cdc9 c957
15+
08cf 2fca 4901 0056 b117 4a0b 0000 00
16+
""".replace(Regex("\\s"), "")
17+
18+
private fun String.toByteString(): ByteString {
19+
val buffer = Buffer()
20+
chunked(2).forEach {
21+
buffer.writeByte(it.toInt(16))
22+
}
23+
24+
return buffer.readByteString()
25+
}
26+
27+
@Test
28+
fun ktorEngineGzipTest() = runTest {
29+
val mockServer = MockServer()
30+
31+
try {
32+
mockServer.enqueue(MockResponse.Builder()
33+
.addHeader("content-type", "application/text")
34+
.addHeader("content-encoding", "gzip")
35+
.body(gzipData.toByteString())
36+
.build())
37+
38+
val engine = KtorHttpEngine()
39+
40+
val response = engine.get(mockServer.url())
41+
.execute()
42+
43+
val result = response.body?.readUtf8()
44+
assertEquals("Hello World", result)
45+
46+
} catch (e: Exception) {
47+
e.printStackTrace()
48+
}
49+
50+
mockServer.stop()
51+
}
52+
}

libraries/apollo-testing-support/src/commonMain/kotlin/com/apollographql/apollo3/testing/runTest.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ class MockServerTest(val mockServer: MockServer, val apolloClient: ApolloClient,
1313
* A convenience function that makes sure the MockServer and ApolloClient are properly closed at the end of the test
1414
*/
1515
@ApolloExperimental
16-
fun mockServerTest(block: suspend MockServerTest.() -> Unit) = com.apollographql.apollo3.testing.internal.runTest(true) {
16+
fun mockServerTest(
17+
clientBuilder: ApolloClient.Builder.() -> Unit = {},
18+
block: suspend MockServerTest.() -> Unit
19+
) = com.apollographql.apollo3.testing.internal.runTest(true) {
1720
val mockServer = MockServer()
1821

1922
val apolloClient = ApolloClient.Builder()
2023
.serverUrl(mockServer.url())
24+
.apply(clientBuilder)
2125
.build()
2226

2327
try {

0 commit comments

Comments
 (0)