From ad8bbe4201d9e2b6cced6960fe15fd30a52babec Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Fri, 15 Dec 2023 13:38:46 +0100 Subject: [PATCH 1/8] allow to set the content-type of String responses --- .../apollographql/apollo3/mockserver/MockServer.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt index e8d759eb085..0f87002e000 100644 --- a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt +++ b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt @@ -267,14 +267,23 @@ fun MockServer(handler: MockServerHandler): MockServer = @ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v4_0_0) fun MockServer.enqueue(string: String = "", delayMs: Long = 0, statusCode: Int = 200) = enqueueString(string, delayMs, statusCode) -fun MockServer.enqueueString(string: String = "", delayMs: Long = 0, statusCode: Int = 200) { +fun MockServer.enqueueString(string: String = "", delayMs: Long = 0, statusCode: Int = 200, contentType: String = "text/plain") { enqueue(MockResponse.Builder() .statusCode(statusCode) .body(string) + .addHeader("content-type", contentType) .delayMillis(delayMs) .build()) } +fun MockServer.enqueueGraphQLString(string: String) { + enqueue(MockResponse.Builder() + .statusCode(200) + .addHeader("content-type", "application/graphql-response+json") + .body(string) + .build()) +} + @ApolloExperimental interface MultipartBody { fun enqueuePart(bytes: ByteString, isLast: Boolean) From 041ff15854027a04908cc00374c2e1dbd967a0e1 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Fri, 15 Dec 2023 13:52:48 +0100 Subject: [PATCH 2/8] fix reading the content-length --- .../apollographql/apollo3/mockserver/http.kt | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt index 8e0910fee56..bbd0ebd23c6 100644 --- a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt +++ b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt @@ -1,7 +1,6 @@ package com.apollographql.apollo3.mockserver import okio.Buffer -import okio.IOException internal interface Reader { val buffer: Buffer @@ -35,7 +34,7 @@ private fun parseRequestLine(line: String): Triple { return Triple(method, match.groupValues[2], match.groupValues[3]) } -internal class ConnectionClosed(cause: Throwable?): Exception("client closed the connection", cause) +internal class ConnectionClosed(cause: Throwable?) : Exception("client closed the connection", cause) internal suspend fun readRequest(reader: Reader): MockRequestBase { suspend fun nextLine(): String { @@ -62,23 +61,21 @@ internal suspend fun readRequest(reader: Reader): MockRequestBase { return buffer2 } - /** - * Check if the client closed the connection - */ - if (reader.buffer.size == 0L) { - try { - reader.fillBuffer() - } catch (e: IOException) { - throw ConnectionClosed(e) - } + var line = try { + nextLine() + } catch (e: Exception) { + /** + * XXX: if the connection is closed in the middle of the first request line, this is detected + * as a normal connection close. + */ + throw ConnectionClosed(e) } - var line = nextLine() - val (method, path, version) = parseRequestLine(line.trimEol()) //println("Line: ${line.trimEol()}") val headers = mutableMapOf() + /** * Read headers */ @@ -90,11 +87,11 @@ internal suspend fun readRequest(reader: Reader): MockRequestBase { } val (key, value) = parseHeader(line.trimEol()) - headers.put(key, value) + headers.put(key.lowercase(), value) } - val contentLength = headers["Content-Length"]?.toLongOrNull() ?: 0 - val transferEncoding = headers["Transfer-Encoding"]?.lowercase() + val contentLength = headers["content-length"]?.toLongOrNull() ?: 0 + val transferEncoding = headers["transfer-encoding"]?.lowercase() check(transferEncoding == null || transferEncoding == "identity" || transferEncoding == "chunked") { "Transfer-Encoding $transferEncoding is not supported" } From 5f5b5bd678b6318a93960e3b7ab8a9687150564f Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Fri, 15 Dec 2023 15:50:20 +0100 Subject: [PATCH 3/8] update apiDump --- libraries/apollo-mockserver/api/apollo-mockserver.api | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/apollo-mockserver/api/apollo-mockserver.api b/libraries/apollo-mockserver/api/apollo-mockserver.api index dbc588bc291..2d757b258ee 100644 --- a/libraries/apollo-mockserver/api/apollo-mockserver.api +++ b/libraries/apollo-mockserver/api/apollo-mockserver.api @@ -75,9 +75,10 @@ public final class com/apollographql/apollo3/mockserver/MockServerKt { public static synthetic fun awaitRequest-8Mi8wO0$default (Lcom/apollographql/apollo3/mockserver/MockServer;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun enqueue (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/lang/String;JI)V public static synthetic fun enqueue$default (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/lang/String;JIILjava/lang/Object;)V + public static final fun enqueueGraphQLString (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/lang/String;)V public static final fun enqueueMultipart (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/util/List;)Ljava/lang/Void; - public static final fun enqueueString (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/lang/String;JI)V - public static synthetic fun enqueueString$default (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/lang/String;JIILjava/lang/Object;)V + public static final fun enqueueString (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/lang/String;JILjava/lang/String;)V + public static synthetic fun enqueueString$default (Lcom/apollographql/apollo3/mockserver/MockServer;Ljava/lang/String;JILjava/lang/String;ILjava/lang/Object;)V } public final class com/apollographql/apollo3/mockserver/TcpServer_concurrentKt { From ed3d506a5e7c585b711d46bbdba7bad49306ead7 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Fri, 15 Dec 2023 16:11:42 +0100 Subject: [PATCH 4/8] headers are case insensitive --- .../apollo3/mockserver/test/ReadRequestTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt b/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt index e30718b86c6..c5754e3a508 100644 --- a/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt +++ b/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt @@ -41,9 +41,9 @@ class ReadRequestTest { assertEquals("/", recordedRequest.path) assertEquals("HTTP/2", recordedRequest.version) assertEquals(mapOf( - "Host" to "github.com", - "User-Agent" to "curl/7.64.1", - "Accept" to "*/*" + "host" to "github.com", + "user-agent" to "curl/7.64.1", + "accept" to "*/*" ), recordedRequest.headers) assertEquals(0, (recordedRequest as MockRequest).body.size) } From 304fd7274c563e32e6f3f7eee52f633cd4dc9495 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Mon, 18 Dec 2023 11:36:53 +0100 Subject: [PATCH 5/8] keep headers stored using the wire case but perform the lookup lowercase --- .../apollo-mockserver/api/apollo-mockserver.api | 4 ++++ .../apollo3/mockserver/MockRequestBase.kt | 14 ++++++++++++++ .../com/apollographql/apollo3/mockserver/http.kt | 6 +++--- .../apollo3/mockserver/test/ReadRequestTest.kt | 6 +++--- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/libraries/apollo-mockserver/api/apollo-mockserver.api b/libraries/apollo-mockserver/api/apollo-mockserver.api index 2d757b258ee..0dcf6bd8491 100644 --- a/libraries/apollo-mockserver/api/apollo-mockserver.api +++ b/libraries/apollo-mockserver/api/apollo-mockserver.api @@ -15,6 +15,10 @@ public abstract interface class com/apollographql/apollo3/mockserver/MockRequest public abstract fun getVersion ()Ljava/lang/String; } +public final class com/apollographql/apollo3/mockserver/MockRequestBaseKt { + public static final fun headerValueOf (Ljava/util/Map;Ljava/lang/String;)Ljava/lang/String; +} + public final class com/apollographql/apollo3/mockserver/MockResponse { public fun ()V public fun (ILkotlinx/coroutines/flow/Flow;Ljava/util/Map;J)V diff --git a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockRequestBase.kt b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockRequestBase.kt index 2db662b79da..c41cfff2bce 100644 --- a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockRequestBase.kt +++ b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockRequestBase.kt @@ -11,9 +11,23 @@ interface MockRequestBase { val method: String val path: String val version: String + + /** + * The request headers + * + * Names are copied as-is from the wire. Since headers are case-insensitive, use [headerValueOf] to retrieve values. + * Values are trimmed. + */ val headers: Map } +/** + * Retrieves the value of the given key, using a case-insensitive matching + */ +fun Map.headerValueOf(name: String): String? { + return entries.firstOrNull { it.key.compareTo(name, ignoreCase = true) == 0 }?.value +} + class MockRequest( override val method: String, override val path: String, diff --git a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt index bbd0ebd23c6..0656608ba84 100644 --- a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt +++ b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/http.kt @@ -87,11 +87,11 @@ internal suspend fun readRequest(reader: Reader): MockRequestBase { } val (key, value) = parseHeader(line.trimEol()) - headers.put(key.lowercase(), value) + headers.put(key, value) } - val contentLength = headers["content-length"]?.toLongOrNull() ?: 0 - val transferEncoding = headers["transfer-encoding"]?.lowercase() + val contentLength = headers.headerValueOf("content-length")?.toLongOrNull() ?: 0 + val transferEncoding = headers.headerValueOf("transfer-encoding")?.lowercase() check(transferEncoding == null || transferEncoding == "identity" || transferEncoding == "chunked") { "Transfer-Encoding $transferEncoding is not supported" } diff --git a/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt b/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt index c5754e3a508..e30718b86c6 100644 --- a/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt +++ b/libraries/apollo-mockserver/src/commonTest/kotlin/com/apollographql/apollo3/mockserver/test/ReadRequestTest.kt @@ -41,9 +41,9 @@ class ReadRequestTest { assertEquals("/", recordedRequest.path) assertEquals("HTTP/2", recordedRequest.version) assertEquals(mapOf( - "host" to "github.com", - "user-agent" to "curl/7.64.1", - "accept" to "*/*" + "Host" to "github.com", + "User-Agent" to "curl/7.64.1", + "Accept" to "*/*" ), recordedRequest.headers) assertEquals(0, (recordedRequest as MockRequest).body.size) } From ef941a8009e8edb34d1a7340d7e1ded4b93d91cc Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Mon, 18 Dec 2023 12:34:43 +0100 Subject: [PATCH 6/8] fix LogInterceptor tests --- .../src/commonTest/kotlin/test/LoggingInterceptorTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt b/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt index dbcf9365db1..e01014925d0 100644 --- a/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt +++ b/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt @@ -87,6 +87,7 @@ class LoggingInterceptorTest { [end of headers] HTTP: 200 + Content-Type: text/plain Content-Length: 322 [end of headers] """) @@ -109,6 +110,7 @@ class LoggingInterceptorTest { {"operationName":"HeroName","variables":{},"query":"query HeroName { hero { name } }"} HTTP: 200 + Content-Type: text/plain Content-Length: 322 [end of headers] { @@ -150,6 +152,7 @@ class LoggingInterceptorTest { {"operationName":"HeroName","variables":{},"query":"query HeroName { hero { name } }"} HTTP: 200 + Content-Type: text/plain Content-Length: 303 [end of headers] { "data": { "hero": { "__typename": "Droid", "name": "R2-D2" } }, "extensions": { "cost": { "requestedQueryCost": 3, "actualQueryCost": 3, "throttleStatus": { "maximumAvailable": 1000, "currentlyAvailable": 997, "restoreRate": 50 } } }} From 3d25533ec226912e3d8e35eb7e0926247f32c632 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Mon, 18 Dec 2023 12:53:42 +0100 Subject: [PATCH 7/8] more consistent --- .../com/apollographql/apollo3/mockserver/MockResponse.kt | 8 +++++++- .../com/apollographql/apollo3/mockserver/MockServer.kt | 2 +- .../apollographql/apollo3/network/http/KtorHttpEngine.kt | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt index a4443b394b9..6195bfa9f22 100644 --- a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt +++ b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt @@ -51,7 +51,13 @@ constructor( fun delayMillis(delayMillis: Long) = apply { this.delayMillis = delayMillis } fun build(): MockResponse { - val headersWithContentLength = if (contentLength == null) headers else headers + mapOf("Content-Length" to contentLength.toString()) + val headersWithContentLength = buildMap { + putAll(headers) + if (contentLength != null) { + put("Content-Length", contentLength.toString()) + } + } + // https://youtrack.jetbrains.com/issue/KT-34480 @Suppress("DEPRECATION_ERROR") return MockResponse(statusCode = statusCode, body = body, headers = headersWithContentLength, delayMillis = delayMillis) diff --git a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt index 0f87002e000..64fdd7ce194 100644 --- a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt +++ b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockServer.kt @@ -271,7 +271,7 @@ fun MockServer.enqueueString(string: String = "", delayMs: Long = 0, statusCode: enqueue(MockResponse.Builder() .statusCode(statusCode) .body(string) - .addHeader("content-type", contentType) + .addHeader("Content-Type", contentType) .delayMillis(delayMs) .build()) } diff --git a/libraries/apollo-runtime/src/jsMain/kotlin/com/apollographql/apollo3/network/http/KtorHttpEngine.kt b/libraries/apollo-runtime/src/jsMain/kotlin/com/apollographql/apollo3/network/http/KtorHttpEngine.kt index 661c67d81f1..a1b33e4eabf 100644 --- a/libraries/apollo-runtime/src/jsMain/kotlin/com/apollographql/apollo3/network/http/KtorHttpEngine.kt +++ b/libraries/apollo-runtime/src/jsMain/kotlin/com/apollographql/apollo3/network/http/KtorHttpEngine.kt @@ -58,6 +58,7 @@ actual class DefaultHttpEngine constructor(private val connectTimeoutMillis: Lon } val responseByteArray: ByteArray = response.body() val responseBufferedSource = Buffer().write(responseByteArray) + return HttpResponse.Builder(statusCode = response.status.value) .body(responseBufferedSource) .addHeaders(response.headers.flattenEntries().map { HttpHeader(it.first, it.second) }) From bec5d185d6af1e3d9282d560c4294a74afa97418 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Mon, 18 Dec 2023 15:02:20 +0100 Subject: [PATCH 8/8] relax the test for JS --- gradle/libraries.toml | 2 +- .../apollo3/mockserver/MockResponse.kt | 2 +- .../kotlin/test/LoggingInterceptorTest.kt | 54 ++++++++++++++++--- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/gradle/libraries.toml b/gradle/libraries.toml index 234a4754660..26bb5949636 100644 --- a/gradle/libraries.toml +++ b/gradle/libraries.toml @@ -34,7 +34,7 @@ kotlinx-datetime = "0.5.0" kotlinx-serialization-runtime = "1.6.2" ksp = "2.0.0-Beta1-1.0.14" okio = "3.6.0" -ktor = "2.3.5" +ktor = "2.3.7" okhttp = "4.11.0" rx-android = "2.0.1" rx-java2 = "2.2.21" diff --git a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt index 6195bfa9f22..167b54167e9 100644 --- a/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt +++ b/libraries/apollo-mockserver/src/commonMain/kotlin/com/apollographql/apollo3/mockserver/MockResponse.kt @@ -57,7 +57,7 @@ constructor( put("Content-Length", contentLength.toString()) } } - + // https://youtrack.jetbrains.com/issue/KT-34480 @Suppress("DEPRECATION_ERROR") return MockResponse(statusCode = statusCode, body = body, headers = headersWithContentLength, delayMillis = delayMillis) diff --git a/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt b/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt index e01014925d0..b9f9cb89f6c 100644 --- a/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt +++ b/tests/integration-tests/src/commonTest/kotlin/test/LoggingInterceptorTest.kt @@ -18,12 +18,12 @@ class LoggingInterceptorTest { private lateinit var mockServer: MockServer private lateinit var logger: Logger - private suspend fun setUp() { + private fun setUp() { mockServer = MockServer() logger = Logger() } - private suspend fun tearDown() { + private fun tearDown() { mockServer.close() } @@ -41,10 +41,49 @@ class LoggingInterceptorTest { } fun assertLog(expected: String) { - assertEquals(expected.trimIndent().lowercase(), fullLog.toString().lowercase().trim()) + val actual = fullLog.toString().lowercase().trim() + if (expected == "") { + assertEquals("", actual) + return + } + + /** + * This is more involved than expected because the response headers order is not preserved on JS. + * The order of HTTP headers is not important unless there are multiple headers with the same name. + * It would be a nice property to keep the HTTP headers order albeit it is currently not the case. + * + * See https://youtrack.jetbrains.com/issue/KTOR-6582/ + */ + expected + .lowercase() + .toStream() + .assertEquals(actual.toStream()) + } + + private fun String.toStream(): Stream { + val responseIndex = indexOf("http: ") + check(responseIndex > 0) + + val request = substring(0, responseIndex) + val responseLines = substring(responseIndex).split("\n") + val endOfHeaders = responseLines.indexOfFirst { it == "[end of headers]" } + return if (endOfHeaders > 0) { + Stream(request, responseLines.subList(1, endOfHeaders), responseLines.subList(0, 1) + responseLines.subList(endOfHeaders, responseLines.size)) + } else { + Stream(request, emptyList(), responseLines) + } + } + + private class Stream(val request: String, val responseHeaders: List, val otherResponseLines: List) { + fun assertEquals(other: Stream) { + assertEquals(request, other.request) + assertEquals(responseHeaders.sorted(), other.responseHeaders.sorted()) + assertEquals(otherResponseLines, other.otherResponseLines) + } } } + @Test fun levelNone() = runTest(before = { setUp() }, after = { tearDown() }) { val client = ApolloClient.Builder() @@ -68,7 +107,7 @@ class LoggingInterceptorTest { Post http://0.0.0.0/ HTTP: 200 - """) + """.trimIndent()) } @Test @@ -90,7 +129,7 @@ class LoggingInterceptorTest { Content-Type: text/plain Content-Length: 322 [end of headers] - """) + """.trimIndent()) } @Test @@ -132,7 +171,7 @@ class LoggingInterceptorTest { } } } - """) + """.trimIndent()) } @Test @@ -142,6 +181,7 @@ class LoggingInterceptorTest { .addHttpInterceptor(LoggingInterceptor(level = Level.BODY, log = logger::log)) .build() mockServer.enqueueString(testFixtureToUtf8("HeroNameResponse.json").replace("\n", "")) + client.query(HeroNameQuery()).execute() logger.assertLog(""" Post http://0.0.0.0/ @@ -156,7 +196,7 @@ class LoggingInterceptorTest { Content-Length: 303 [end of headers] { "data": { "hero": { "__typename": "Droid", "name": "R2-D2" } }, "extensions": { "cost": { "requestedQueryCost": 3, "actualQueryCost": 3, "throttleStatus": { "maximumAvailable": 1000, "currentlyAvailable": 997, "restoreRate": 50 } } }} - """) + """.trimIndent()) } @Test