diff --git a/apollo-http-cache/api/apollo-http-cache.api b/apollo-http-cache/api/apollo-http-cache.api index 7150bb3e9ab..7e8b745b69d 100644 --- a/apollo-http-cache/api/apollo-http-cache.api +++ b/apollo-http-cache/api/apollo-http-cache.api @@ -2,7 +2,7 @@ public abstract interface class com/apollographql/apollo3/cache/http/ApolloHttpC public abstract fun clearAll ()V public abstract fun read (Ljava/lang/String;)Lcom/apollographql/apollo3/api/http/HttpResponse; public abstract fun remove (Ljava/lang/String;)V - public abstract fun write (Lcom/apollographql/apollo3/api/http/HttpResponse;Ljava/lang/String;)V + public abstract fun write (Lcom/apollographql/apollo3/api/http/HttpResponse;Ljava/lang/String;)Lcom/apollographql/apollo3/api/http/HttpResponse; } public final class com/apollographql/apollo3/cache/http/CachingHttpInterceptor : com/apollographql/apollo3/network/http/HttpInterceptor { @@ -39,7 +39,7 @@ public final class com/apollographql/apollo3/cache/http/DiskLruHttpCache : com/a public final fun delete ()V public fun read (Ljava/lang/String;)Lcom/apollographql/apollo3/api/http/HttpResponse; public fun remove (Ljava/lang/String;)V - public fun write (Lcom/apollographql/apollo3/api/http/HttpResponse;Ljava/lang/String;)V + public fun write (Lcom/apollographql/apollo3/api/http/HttpResponse;Ljava/lang/String;)Lcom/apollographql/apollo3/api/http/HttpResponse; } public final class com/apollographql/apollo3/cache/http/DiskLruHttpCache$Companion { diff --git a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/ApolloHttpCache.kt b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/ApolloHttpCache.kt index d170f1f73c7..4f34b096895 100644 --- a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/ApolloHttpCache.kt +++ b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/ApolloHttpCache.kt @@ -8,13 +8,14 @@ interface ApolloHttpCache { /** * Store the [response] with the given [cacheKey] into the cache. - * Note: the response's body is not consumed nor closed. + * The response's body is not consumed nor closed. + * @return a new [HttpResponse] whose body, when read, will write the contents to the cache. */ - fun write(response: HttpResponse, cacheKey: String) + fun write(response: HttpResponse, cacheKey: String): HttpResponse @Throws(IOException::class) fun clearAll() @Throws(IOException::class) fun remove(cacheKey: String) -} \ No newline at end of file +} diff --git a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/CachingHttpInterceptor.kt b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/CachingHttpInterceptor.kt index 7dc623dcb74..43069094ee7 100644 --- a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/CachingHttpInterceptor.kt +++ b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/CachingHttpInterceptor.kt @@ -31,8 +31,7 @@ class CachingHttpInterceptor( override suspend fun intercept(request: HttpRequest, chain: HttpInterceptorChain): HttpResponse { val policy = request.headers.valueOf(CACHE_FETCH_POLICY_HEADER) ?: defaultPolicy(request) - val cacheKey = cacheKey(request) - + val cacheKey = request.headers.valueOf(CACHE_KEY_HEADER)!! when (policy) { CACHE_FIRST -> { val cacheException: ApolloException @@ -110,7 +109,7 @@ class CachingHttpInterceptor( if (response.statusCode in 200..299 && !doNotStore) { // Note: this write may fail if the same cacheKey is being stored by another thread. // This is OK though: the other thread will be the one that stores it in the cache (see issue #3664). - lruHttpCache.write( + return lruHttpCache.write( response.newBuilder() .addHeaders( listOf( diff --git a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/DiskLruHttpCache.kt b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/DiskLruHttpCache.kt index 728c23dd67b..1d078b33ad1 100644 --- a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/DiskLruHttpCache.kt +++ b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/DiskLruHttpCache.kt @@ -5,7 +5,11 @@ import com.apollographql.apollo3.api.http.HttpHeader import com.apollographql.apollo3.api.http.HttpResponse import com.apollographql.apollo3.cache.http.internal.DiskLruCache import com.squareup.moshi.Moshi +import okio.Buffer import okio.FileSystem +import okio.Sink +import okio.Source +import okio.Timeout import okio.buffer import java.io.File import java.io.IOException @@ -46,16 +50,13 @@ class DiskLruHttpCache(private val fileSystem: FileSystem, private val directory /** * Store the [response] with the given [cacheKey] into the cache. - * Note: the response's body is not consumed nor closed. + * A new [HttpResponse] is returned whose body, when read, will write the contents to the cache. + * The response's body is not consumed nor closed. */ - override fun write(response: HttpResponse, cacheKey: String) { + override fun write(response: HttpResponse, cacheKey: String): HttpResponse { val editor = cacheLock.read { cache.edit(cacheKey) - } - - if (editor == null) { - return - } + } ?: return response try { editor.newSink(ENTRY_HEADERS).buffer().use { @@ -69,15 +70,14 @@ class DiskLruHttpCache(private val fileSystem: FileSystem, private val directory ) adapter.toJson(it, map) } - editor.newSink(ENTRY_BODY).buffer().use { - val responseBody = response.body - if (responseBody != null) { - it.writeAll(responseBody.peek()) - } - } - editor.commit() + val bodySink = editor.newSink(ENTRY_BODY) + return HttpResponse.Builder(response.statusCode).apply { + headers(response.headers) + response.body?.let { body(ProxySource(it, bodySink, editor).buffer()) } + }.build() } catch (e: Exception) { editor.abort() + return response } } @@ -104,6 +104,71 @@ class DiskLruHttpCache(private val fileSystem: FileSystem, private val directory } } + /** + * A [Source] that writes to the given cache sink as it is read. + * + * It commits all successful reads, even if they do not read until EOF. This is so that we can cache Json with extra trailing whitespace. + * If an error happens when reading the original source or writing to the cache sink, the edit is aborted. + * The commit or abort is done on [close]. + */ + private class ProxySource( + private val originalSource: Source, + private val sink: Sink, + private val cacheEditor: DiskLruCache.Editor, + ) : Source { + + private val buffer = Buffer() + private var hasClosedAndCommitted: Boolean = false + private var hasReadError: Boolean = false + + override fun read(sink: Buffer, byteCount: Long): Long { + val read = try { + originalSource.read(buffer, byteCount) + } catch (e: Exception) { + hasReadError = true + throw e + } + + if (read == -1L) { + // We're at EOF + return -1L + } + try { + buffer.peek().readAll(this.sink) + } catch (e: Exception) { + hasReadError = true + } + try { + sink.writeAll(buffer) + } catch (e: Exception) { + hasReadError = true + throw e + } + return read + } + + override fun close() { + if (!hasClosedAndCommitted) { + try { + sink.close() + if (hasReadError) { + cacheEditor.abort() + } else { + cacheEditor.commit() + } + } catch (e: Exception) { + // Silently ignore cache write errors + } finally { + hasClosedAndCommitted = true + } + originalSource.close() + } + } + + override fun timeout(): Timeout = originalSource.timeout() + } + + companion object { private const val VERSION = 99991 private const val ENTRY_HEADERS = 0 diff --git a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/HttpCacheExtensions.kt b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/HttpCacheExtensions.kt index ea4bcf06203..584eb1f93e1 100644 --- a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/HttpCacheExtensions.kt +++ b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/HttpCacheExtensions.kt @@ -10,11 +10,17 @@ import com.apollographql.apollo3.api.Mutation import com.apollographql.apollo3.api.Operation import com.apollographql.apollo3.api.Query import com.apollographql.apollo3.api.Subscription +import com.apollographql.apollo3.api.http.HttpRequest +import com.apollographql.apollo3.api.http.HttpResponse import com.apollographql.apollo3.interceptor.ApolloInterceptor import com.apollographql.apollo3.interceptor.ApolloInterceptorChain import com.apollographql.apollo3.network.http.HttpInfo +import com.apollographql.apollo3.network.http.HttpInterceptor +import com.apollographql.apollo3.network.http.HttpInterceptorChain import com.apollographql.apollo3.network.http.HttpNetworkTransport import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach import java.io.File enum class HttpFetchPolicy { @@ -54,28 +60,43 @@ fun ApolloClient.Builder.httpCache( directory: File, maxSize: Long, ): ApolloClient.Builder { - - return addHttpInterceptor( - CachingHttpInterceptor( - directory = directory, - maxSize = maxSize, - ) + val cachingHttpInterceptor = CachingHttpInterceptor( + directory = directory, + maxSize = maxSize, + ) + var cacheKey: String? = null + return addHttpInterceptor(object : HttpInterceptor { + override suspend fun intercept(request: HttpRequest, chain: HttpInterceptorChain): HttpResponse { + cacheKey = CachingHttpInterceptor.cacheKey(request) + return chain.proceed(request.newBuilder().addHeader(CachingHttpInterceptor.CACHE_KEY_HEADER, cacheKey!!).build()) + } + }).addHttpInterceptor( + cachingHttpInterceptor ).addInterceptor(object : ApolloInterceptor { override fun intercept(request: ApolloRequest, chain: ApolloInterceptorChain): Flow> { - return chain.proceed(request.newBuilder() - .addHttpHeader( - CachingHttpInterceptor.CACHE_OPERATION_TYPE_HEADER, - when (request.operation) { - is Query<*> -> "query" - is Mutation<*> -> "mutation" - is Subscription<*> -> "subscription" - else -> error("Unknown operation type") - } - ) - .build() - ) + return chain.proceed( + request.newBuilder() + .addHttpHeader( + CachingHttpInterceptor.CACHE_OPERATION_TYPE_HEADER, + when (request.operation) { + is Query<*> -> "query" + is Mutation<*> -> "mutation" + is Subscription<*> -> "subscription" + else -> error("Unknown operation type") + } + ) + .build() + ).catch { throwable -> + // Revert caching of responses with errors + cacheKey?.let { cachingHttpInterceptor.cache.remove(it) } + throw throwable + }.onEach { response -> + // Revert caching of responses with errors + if (response.hasErrors()) { + cacheKey?.let { cachingHttpInterceptor.cache.remove(it) } + } + } } - }) } diff --git a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/internal/DiskLruCache.kt b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/internal/DiskLruCache.kt index 6f4a72321c8..3615de96ecd 100644 --- a/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/internal/DiskLruCache.kt +++ b/apollo-http-cache/src/main/kotlin/com/apollographql/apollo3/cache/http/internal/DiskLruCache.kt @@ -862,7 +862,6 @@ internal class DiskLruCache( } } } - } inner class Entry internal constructor(val key: String) { diff --git a/apollo-http-cache/src/test/kotlin/com/apollographql/apollo3/cache/http/internal/CachingHttpInterceptorTest.kt b/apollo-http-cache/src/test/kotlin/com/apollographql/apollo3/cache/http/internal/CachingHttpInterceptorTest.kt index da75f871ad2..01359833149 100644 --- a/apollo-http-cache/src/test/kotlin/com/apollographql/apollo3/cache/http/internal/CachingHttpInterceptorTest.kt +++ b/apollo-http-cache/src/test/kotlin/com/apollographql/apollo3/cache/http/internal/CachingHttpInterceptorTest.kt @@ -21,6 +21,7 @@ import java.io.File import kotlin.test.assertEquals import kotlin.test.assertFailsWith +@Suppress("BlockingMethodInNonBlockingContext") class CachingHttpInterceptorTest { private lateinit var mockServer: MockServer private lateinit var interceptor: CachingHttpInterceptor @@ -37,16 +38,22 @@ class CachingHttpInterceptorTest { @Test fun successResponsesAreCached() { - mockServer.enqueue(MockResponse(statusCode = 200, body = "success")) + val body = "success" + mockServer.enqueue(MockResponse(statusCode = 200, body = body)) runBlocking { val request = HttpRequest.Builder( method = HttpMethod.Get, url = mockServer.url(), - ).build() + ) + .withCacheKey() + .build() var response = interceptor.intercept(request, chain) - assertEquals("success", response.body?.readUtf8()) + assertEquals(body, response.body?.readUtf8()) + + // Cache is committed when the body is closed + response.body?.close() // 2nd request should hit the cache response = interceptor.intercept( @@ -55,7 +62,7 @@ class CachingHttpInterceptorTest { .build(), chain ) - assertEquals("success", response.body?.readUtf8()) + assertEquals(body, response.body?.readUtf8()) assertEquals("true", response.headers.valueOf(CachingHttpInterceptor.FROM_CACHE)) } } @@ -68,7 +75,9 @@ class CachingHttpInterceptorTest { val request = HttpRequest.Builder( method = HttpMethod.Get, url = mockServer.url(), - ).build() + ) + .withCacheKey() + .build() // Warm the cache val response = interceptor.intercept(request, chain) @@ -88,17 +97,21 @@ class CachingHttpInterceptorTest { @Test fun timeoutWorks() { - mockServer.enqueue(MockResponse(statusCode = 200, body = "success")) - + val body = "success" + mockServer.enqueue(MockResponse(statusCode = 200, body = body)) runBlocking { val request = HttpRequest.Builder( method = HttpMethod.Get, url = mockServer.url(), - ).build() + ) + .withCacheKey() + .build() // Warm the cache var response = interceptor.intercept(request, chain) - assertEquals("success", response.body?.readUtf8()) + assertEquals(body, response.body?.readUtf8()) + // Cache is committed when the body is closed + response.body?.close() // 2nd request should hit the cache response = interceptor.intercept( @@ -107,7 +120,9 @@ class CachingHttpInterceptorTest { .build(), chain ) - assertEquals("success", response.body?.readUtf8()) + assertEquals(body, response.body?.readUtf8()) + // Cache is committed when the body is closed + response.body?.close() delay(1000) // 3rd request with a 500ms timeout should miss @@ -136,7 +151,9 @@ class CachingHttpInterceptorTest { val request = HttpRequest.Builder( method = HttpMethod.Get, url = mockServer.url(), - ).build() + ) + .withCacheKey() + .build() val response = interceptor.intercept(request, chain) assertEquals("success", response.body?.readUtf8()) @@ -147,6 +164,11 @@ class CachingHttpInterceptorTest { } } +private fun HttpRequest.Builder.withCacheKey(): HttpRequest.Builder { + val cacheKey = CachingHttpInterceptor.cacheKey(build()) + return addHeader(CachingHttpInterceptor.CACHE_KEY_HEADER, cacheKey) +} + private class TestHttpInterceptorChain : HttpInterceptorChain { val engine = DefaultHttpEngine() diff --git a/apollo-mpp-utils/api/apollo-mpp-utils.api b/apollo-mpp-utils/api/apollo-mpp-utils.api index 66f0a735956..adeacbd9c08 100644 --- a/apollo-mpp-utils/api/apollo-mpp-utils.api +++ b/apollo-mpp-utils/api/apollo-mpp-utils.api @@ -9,6 +9,8 @@ public final class com/apollographql/apollo3/mpp/Platform : java/lang/Enum { public final class com/apollographql/apollo3/mpp/UtilsKt { public static final fun assertMainThreadOnNative ()V public static final fun currentThreadId ()Ljava/lang/String; + public static final fun currentThreadName ()Ljava/lang/String; + public static final fun currentTimeFormatted ()Ljava/lang/String; public static final fun currentTimeMillis ()J public static final fun ensureNeverFrozen (Ljava/lang/Object;)V public static final fun freeze (Ljava/lang/Object;)V diff --git a/apollo-mpp-utils/build.gradle.kts b/apollo-mpp-utils/build.gradle.kts index d4686bdbb4b..f63accd24bb 100644 --- a/apollo-mpp-utils/build.gradle.kts +++ b/apollo-mpp-utils/build.gradle.kts @@ -6,6 +6,11 @@ configureMppDefaults(withLinux = false) kotlin { sourceSets { + val commonMain by getting { + dependencies { + api(projects.apolloAnnotations) + } + } } } diff --git a/apollo-mpp-utils/src/appleMain/kotlin/com/apollographql/apollo3/mpp/utils.kt b/apollo-mpp-utils/src/appleMain/kotlin/com/apollographql/apollo3/mpp/utils.kt index 5896ea7161d..e73d9e2122a 100644 --- a/apollo-mpp-utils/src/appleMain/kotlin/com/apollographql/apollo3/mpp/utils.kt +++ b/apollo-mpp-utils/src/appleMain/kotlin/com/apollographql/apollo3/mpp/utils.kt @@ -1,5 +1,7 @@ package com.apollographql.apollo3.mpp +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter import platform.Foundation.NSThread import platform.posix.pthread_self import kotlin.native.concurrent.ensureNeverFrozen @@ -11,10 +13,24 @@ actual fun currentTimeMillis(): Long { return getTimeMillis() } +private val nsDateFormatter by lazy { NSDateFormatter().apply { dateFormat = "HH:mm:ss.SSS" } } + +actual fun currentTimeFormatted(): String { + return nsDateFormatter.stringFromDate(NSDate()) +} + actual fun currentThreadId(): String { return pthread_self()?.rawValue.toString() } +actual fun currentThreadName(): String { + return if (NSThread.isMainThread) { + "main" + } else { + currentThreadId() + } +} + actual fun ensureNeverFrozen(obj: Any) { obj.ensureNeverFrozen() } diff --git a/apollo-mpp-utils/src/commonMain/kotlin/com/apollographql/apollo3/mpp/utils.kt b/apollo-mpp-utils/src/commonMain/kotlin/com/apollographql/apollo3/mpp/utils.kt index f3a1d2e4034..8a5c8ccfc20 100644 --- a/apollo-mpp-utils/src/commonMain/kotlin/com/apollographql/apollo3/mpp/utils.kt +++ b/apollo-mpp-utils/src/commonMain/kotlin/com/apollographql/apollo3/mpp/utils.kt @@ -1,10 +1,32 @@ +@file:JvmName("-utils") + package com.apollographql.apollo3.mpp +import com.apollographql.apollo3.annotations.ApolloInternal +import kotlin.jvm.JvmName + expect fun currentTimeMillis(): Long + +/** + * The current time as a human-readable String. Used for debugging. + */ +@ApolloInternal +expect fun currentTimeFormatted(): String + expect fun currentThreadId(): String + +/** + * The current thread name ("main" for the main thread). Used for debugging. + */ +@ApolloInternal +expect fun currentThreadName(): String + expect fun ensureNeverFrozen(obj: Any) + expect fun isFrozen(obj: Any): Boolean + expect fun freeze(obj: Any) + expect fun assertMainThreadOnNative() enum class Platform { @@ -19,3 +41,8 @@ enum class Platform { */ expect fun platform(): Platform +// Helpful for debugging, but not wanted in the final library - uncomment as needed +//@OptIn(ApolloInternal::class) +//fun log(message: String) { +// println("${currentTimeFormatted()} [${currentThreadName()}] $message") +//} diff --git a/apollo-mpp-utils/src/jsMain/kotlin/com/apollographql/apollo3/mpp/utils.kt b/apollo-mpp-utils/src/jsMain/kotlin/com/apollographql/apollo3/mpp/utils.kt index 8cfac52c1f3..9a1d793dd55 100644 --- a/apollo-mpp-utils/src/jsMain/kotlin/com/apollographql/apollo3/mpp/utils.kt +++ b/apollo-mpp-utils/src/jsMain/kotlin/com/apollographql/apollo3/mpp/utils.kt @@ -6,10 +6,18 @@ actual fun currentTimeMillis(): Long { return Date().getTime().toLong() } +actual fun currentTimeFormatted(): String { + return Date().toISOString() +} + actual fun currentThreadId(): String { return "js" } +actual fun currentThreadName(): String { + return currentThreadId() +} + actual fun ensureNeverFrozen(obj: Any) { } diff --git a/apollo-mpp-utils/src/jvmMain/kotlin/com/apollographql/apollo3/mpp/utils.kt b/apollo-mpp-utils/src/jvmMain/kotlin/com/apollographql/apollo3/mpp/utils.kt index ddfcdfd32dc..8b76382aa2b 100644 --- a/apollo-mpp-utils/src/jvmMain/kotlin/com/apollographql/apollo3/mpp/utils.kt +++ b/apollo-mpp-utils/src/jvmMain/kotlin/com/apollographql/apollo3/mpp/utils.kt @@ -1,13 +1,26 @@ package com.apollographql.apollo3.mpp +import java.text.SimpleDateFormat +import java.util.Locale + actual fun currentTimeMillis(): Long { return System.currentTimeMillis() } +private val simpleDateFormat by lazy { SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT) } + +actual fun currentTimeFormatted(): String { + return simpleDateFormat.format(currentTimeMillis()) +} + actual fun currentThreadId(): String { return Thread.currentThread().id.toString() } +actual fun currentThreadName(): String { + return Thread.currentThread().name +} + actual fun ensureNeverFrozen(obj: Any) { } @@ -18,4 +31,4 @@ actual fun freeze(obj: Any) { actual fun assertMainThreadOnNative() { } -actual fun platform() = Platform.Jvm \ No newline at end of file +actual fun platform() = Platform.Jvm diff --git a/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/internal/multipart.kt b/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/internal/multipart.kt index 58a3f2b65d0..7e83a0356f5 100644 --- a/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/internal/multipart.kt +++ b/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/internal/multipart.kt @@ -18,6 +18,7 @@ import okio.BufferedSource */ @OptIn(ApolloInternal::class) internal fun multipartBodyFlow(response: HttpResponse): Flow { + val worker = NonMainWorker() var multipartReader: MultipartReader? = null return flow { multipartReader = MultipartReader( @@ -26,7 +27,8 @@ internal fun multipartBodyFlow(response: HttpResponse): Flow { ?: throw ApolloException("Expected the Content-Type to have a boundary parameter") ) while (true) { - val part = multipartReader!!.nextPart() ?: break + // Read the body in a background thread because it is blocking + val part = worker.doWork { multipartReader!!.nextPart() } ?: break emit(part.body) } }.onCompletion { diff --git a/tests/defer/build.gradle.kts b/tests/defer/build.gradle.kts index 0faeaf4e22b..2573fb4266e 100644 --- a/tests/defer/build.gradle.kts +++ b/tests/defer/build.gradle.kts @@ -40,6 +40,12 @@ kotlin { implementation(npm("graphql", "canary-pr-2839")) } } + + val jvmTest by getting { + dependencies { + implementation("com.apollographql.apollo3:apollo-http-cache") + } + } } } diff --git a/tests/defer/src/commonTest/kotlin/test/DeferTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferTest.kt index 276c3d21cbb..f07a7aec350 100644 --- a/tests/defer/src/commonTest/kotlin/test/DeferTest.kt +++ b/tests/defer/src/commonTest/kotlin/test/DeferTest.kt @@ -15,7 +15,6 @@ import defer.WithFragmentSpreadsQuery import defer.WithInlineFragmentsQuery import defer.fragment.ComputerFields import defer.fragment.ScreenFields -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.toList import kotlin.test.Test import kotlin.test.assertEquals @@ -270,10 +269,8 @@ class DeferTest { actualDelays += currentTimeMillis() - lastEmitTime lastEmitTime = currentTimeMillis() } - // Last 2 emissions can arrive together, so ignore last element - for (d in actualDelays.dropLast(1)) { - // Allow a 10% margin for inaccuracies - assertTrue(d >= delay / 1.1) + for (d in actualDelays) { + assertTrue(d > 0) } } } diff --git a/tests/defer/src/jvmTest/kotlin/test/DeferJvmTest.kt b/tests/defer/src/jvmTest/kotlin/test/DeferJvmTest.kt new file mode 100644 index 00000000000..ee6a0074af0 --- /dev/null +++ b/tests/defer/src/jvmTest/kotlin/test/DeferJvmTest.kt @@ -0,0 +1,74 @@ +package test + +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.annotations.ApolloExperimental +import com.apollographql.apollo3.cache.http.HttpFetchPolicy +import com.apollographql.apollo3.cache.http.httpCache +import com.apollographql.apollo3.cache.http.httpFetchPolicy +import com.apollographql.apollo3.mockserver.MockServer +import com.apollographql.apollo3.mockserver.enqueueMultipart +import com.apollographql.apollo3.mpp.currentTimeMillis +import com.apollographql.apollo3.testing.runTest +import defer.WithFragmentSpreadsQuery +import defer.fragment.ComputerFields +import defer.fragment.ScreenFields +import kotlinx.coroutines.flow.last +import org.junit.Test +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ApolloExperimental::class) +class DeferJvmTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + + private suspend fun setUp() { + val dir = File("build/httpCache") + dir.deleteRecursively() + mockServer = MockServer() + apolloClient = ApolloClient.Builder() + .serverUrl(mockServer.url()) + .httpCache(dir, 4_000) + .build() + } + + private suspend fun tearDown() { + mockServer.stop() + } + + @Test + fun payloadsAreReceivedIncrementallyWithHttpCache() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true}""", + """{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"path":["computers",1],"hasNext":true}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":true,"label":"a"}""", + """{"data":{"isColor":true},"path":["computers",1,"screen"],"hasNext":false,"label":"a"}""", + ) + + val delay = 200L + mockServer.enqueueMultipart(jsonList, chunksDelayMillis = delay) + + var lastEmitTime = currentTimeMillis() + apolloClient.query(WithFragmentSpreadsQuery()).toFlow().collect { + assertTrue(currentTimeMillis() - lastEmitTime > 0) + lastEmitTime = currentTimeMillis() + } + + // Also check that caching worked + val actual = apolloClient.query(WithFragmentSpreadsQuery()).httpFetchPolicy(HttpFetchPolicy.CacheOnly).toFlow().last().dataAssertNoErrors + val expected = WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false)))), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true)))), + ) + ) + + assertEquals(expected, actual) + } +} diff --git a/tests/http-cache/src/test/kotlin/HttpCacheTest.kt b/tests/http-cache/src/test/kotlin/HttpCacheTest.kt index 387d2f43942..b7c5a6c503c 100644 --- a/tests/http-cache/src/test/kotlin/HttpCacheTest.kt +++ b/tests/http-cache/src/test/kotlin/HttpCacheTest.kt @@ -1,10 +1,10 @@ import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.Optional import com.apollographql.apollo3.cache.http.HttpFetchPolicy import com.apollographql.apollo3.cache.http.httpCache import com.apollographql.apollo3.cache.http.httpExpireTimeout import com.apollographql.apollo3.cache.http.httpFetchPolicy import com.apollographql.apollo3.cache.http.isFromHttpCache +import com.apollographql.apollo3.exception.ApolloParseException import com.apollographql.apollo3.exception.HttpCacheMissException import com.apollographql.apollo3.mockserver.MockResponse import com.apollographql.apollo3.mockserver.MockServer @@ -194,5 +194,34 @@ class HttpCacheTest { mockServer.takeRequest() } } -} + @Test + fun incompleteJsonIsNotCached() = runTest(before = { before() }, after = { tearDown() }) { + mockServer.enqueue("""{"data":""") + assertFailsWith { + apolloClient.query(GetRandomQuery()).execute() + } + // Should not have been cached + assertFailsWith { + apolloClient.query(GetRandomQuery()).httpFetchPolicy(HttpFetchPolicy.CacheOnly).execute() + } + } + + @Test + fun responseWithGraphQLErrorIsNotCached() = runTest(before = { before() }, after = { tearDown() }) { + mockServer.enqueue(""" + { + "data": { + "random": 42 + }, + "errors": [ { "message": "GraphQL error" } ] + } + """) + apolloClient.query(GetRandomQuery()).execute() + // Should not have been cached + assertFailsWith { + apolloClient.query(GetRandomQuery()).httpFetchPolicy(HttpFetchPolicy.CacheOnly).execute() + } + } + +}