diff --git a/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/AdapterContext.kt b/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/AdapterContext.kt index 54c6b19d757..bc3567cb2e4 100644 --- a/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/AdapterContext.kt +++ b/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/AdapterContext.kt @@ -19,7 +19,11 @@ class AdapterContext private constructor( } fun hasDeferredFragment(path: List, label: String?): Boolean { - return mergedDeferredFragmentIds?.contains(DeferredFragmentIdentifier(path, label)) == true + if (mergedDeferredFragmentIds == null) { + // By default, parse all deferred fragments - this is the case when parsing from the normalized cache. + return true + } + return mergedDeferredFragmentIds.contains(DeferredFragmentIdentifier(path, label)) } class Builder { diff --git a/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/ClientCacheExtensions.kt b/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/ClientCacheExtensions.kt index c05c0ce2346..b9b3dcd6233 100644 --- a/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/ClientCacheExtensions.kt +++ b/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/ClientCacheExtensions.kt @@ -37,7 +37,8 @@ enum class FetchPolicy { /** * Try the cache, if that failed, try the network. * - * An [ApolloCompositeException] is thrown if the data is not in the cache and the network call failed, otherwise 1 value is emitted. + * An [ApolloCompositeException] is thrown if the data is not in the cache and the network call failed. + * If coming from the cache 1 value is emitted, otherwise 1 or multiple values can be emitted from the network. * * This is the default behaviour. */ @@ -53,14 +54,15 @@ enum class FetchPolicy { /** * Try the network, if that failed, try the cache. * - * An [ApolloCompositeException] is thrown if the network call failed and the data is not in the cache, otherwise 1 value is emitted. + * An [ApolloCompositeException] is thrown if the network call failed and the data is not in the cache. + * If coming from the network 1 or multiple values can be emitted, otherwise 1 value is emitted from the cache. */ NetworkFirst, /** * Only try the network. * - * An [ApolloException] is thrown if the network call failed, otherwise 1 value is emitted. + * An [ApolloException] is thrown if the network call failed, otherwise 1 or multiple values can be emitted. */ NetworkOnly, @@ -68,7 +70,7 @@ enum class FetchPolicy { * Try the cache, then also try the network. * * If the data is in the cache, it is emitted, if not, no exception is thrown at that point. Then the network call is made, and if - * successful, the value is emitted, if not, either an [ApolloCompositeException] (both cache miss and network failed) or an + * successful the value(s) are emitted, otherwise either an [ApolloCompositeException] (both cache miss and network failed) or an * [ApolloException] (only network failed) is thrown. */ CacheAndNetwork, diff --git a/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/FetchPolicyInterceptors.kt b/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/FetchPolicyInterceptors.kt index 2faa087b03d..13ecce9c675 100644 --- a/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/FetchPolicyInterceptors.kt +++ b/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/FetchPolicyInterceptors.kt @@ -13,7 +13,9 @@ import com.apollographql.apollo3.interceptor.ApolloInterceptor import com.apollographql.apollo3.interceptor.ApolloInterceptorChain import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.singleOrNull import kotlin.jvm.JvmName @@ -64,7 +66,7 @@ val CacheFirstInterceptor = object : ApolloInterceptor { return@flow } - val networkResponse = chain.proceed( + val networkResponses = chain.proceed( request = request ).catch { if (it is ApolloException) { @@ -72,26 +74,25 @@ val CacheFirstInterceptor = object : ApolloInterceptor { } else { throw it } - }.singleOrNull() + }.map { response -> + response.newBuilder() + .cacheInfo( + response.cacheInfo!! + .newBuilder() + .cacheMissException(cacheException as? CacheMissException) + .build() + ) + .build() + } - if (networkResponse != null) { - emit( - networkResponse.newBuilder() - .cacheInfo( - networkResponse.cacheInfo!! - .newBuilder() - .cacheMissException(cacheException as? CacheMissException) - .build() - ) - .build() + emitAll(networkResponses) + + if (networkException != null) { + throw ApolloCompositeException( + first = cacheException, + second = networkException ) - return@flow } - - throw ApolloCompositeException( - first = cacheException, - second = networkException - ) } } } @@ -102,7 +103,7 @@ val NetworkFirstInterceptor = object : ApolloInterceptor { var cacheException: ApolloException? = null var networkException: ApolloException? = null - val networkResponse = chain.proceed( + val networkResponses = chain.proceed( request = request ).catch { if (it is ApolloException) { @@ -110,10 +111,10 @@ val NetworkFirstInterceptor = object : ApolloInterceptor { } else { throw it } - }.singleOrNull() + } - if (networkResponse != null) { - emit(networkResponse) + emitAll(networkResponses) + if (networkException == null) { return@flow } @@ -179,35 +180,36 @@ val CacheAndNetworkInterceptor = object : ApolloInterceptor { emit(cacheResponse.newBuilder().isLast(false).build()) } - val networkResponse = chain.proceed(request) + val networkResponses = chain.proceed(request) .catch { if (it is ApolloException) { networkException = it } else { throw it } - }.singleOrNull() - - if (networkResponse != null) { - emit( - networkResponse.newBuilder() + } + .map { response -> + response.newBuilder() .cacheInfo( - networkResponse.cacheInfo!! + response.cacheInfo!! .newBuilder() .cacheMissException(cacheException as? CacheMissException) .build() ) .build() - ) - return@flow - } - if (cacheException != null) { - throw ApolloCompositeException( - first = cacheException, - second = networkException - ) + } + + emitAll(networkResponses) + + if (networkException != null) { + if (cacheException != null) { + throw ApolloCompositeException( + first = cacheException, + second = networkException + ) + } + throw networkException!! } - throw networkException!! } } } diff --git a/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/internal/ApolloCacheInterceptor.kt b/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/internal/ApolloCacheInterceptor.kt index 12ebc2d165f..42e26e9ffc0 100644 --- a/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/internal/ApolloCacheInterceptor.kt +++ b/apollo-normalized-cache/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/internal/ApolloCacheInterceptor.kt @@ -26,10 +26,13 @@ import com.apollographql.apollo3.interceptor.ApolloInterceptor import com.apollographql.apollo3.interceptor.ApolloInterceptorChain import com.apollographql.apollo3.mpp.currentTimeMillis import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.single import kotlinx.coroutines.launch internal class ApolloCacheInterceptor( @@ -143,26 +146,43 @@ internal class ApolloCacheInterceptor( /** * This doesn't use [readFromNetwork] so that we can publish all keys all at once after the keys have been rolled back */ - var response: ApolloResponse? = null - var exception: ApolloException? = null - try { - response = chain.proceed(request).single() - } catch (e: ApolloException) { - exception = e - } + var networkException: ApolloException? = null + val networkResponses: Flow> = chain.proceed(request) + .catch { + if (it is ApolloException) { + networkException = it + } else { + throw it + } + } - val optimisticKeys = if (optimisticData != null) { - store.rollbackOptimisticUpdates(request.requestUuid, publish = false) - } else { - emptySet() - } + var optimisticKeys: Set? = null - if (response != null) { - maybeWriteToCache(request, response, customScalarAdapters, optimisticKeys) + var previousResponse: ApolloResponse? = null + networkResponses.collect { response -> + if (optimisticData != null && previousResponse != null) { + throw ApolloException("Apollo: optimistic updates can only be applied with one network response") + } + previousResponse = response + if (optimisticKeys == null) optimisticKeys = if (optimisticData != null) { + store.rollbackOptimisticUpdates(request.requestUuid, publish = false) + } else { + emptySet() + } + + maybeWriteToCache(request, response, customScalarAdapters, optimisticKeys!!) emit(response) - } else { - store.publish(optimisticKeys) - throw exception!! + } + + if (networkException != null) { + if (optimisticKeys == null) optimisticKeys = if (optimisticData != null) { + store.rollbackOptimisticUpdates(request.requestUuid, publish = false) + } else { + emptySet() + } + + store.publish(optimisticKeys!!) + throw networkException!! } } } @@ -172,13 +192,11 @@ internal class ApolloCacheInterceptor( val fetchFromCache = request.fetchFromCache return flow { - emit( - if (fetchFromCache) { - readFromCache(request, customScalarAdapters) - } else { - readFromNetwork(request, chain, customScalarAdapters) - } - ) + if (fetchFromCache) { + emit(readFromCache(request, customScalarAdapters)) + } else { + emitAll(readFromNetwork(request, chain, customScalarAdapters)) + } } } @@ -238,18 +256,18 @@ internal class ApolloCacheInterceptor( request: ApolloRequest, chain: ApolloInterceptorChain, customScalarAdapters: CustomScalarAdapters, - ): ApolloResponse { + ): Flow> { val startMillis = currentTimeMillis() - val networkResponse = chain.proceed(request).onEach { + return chain.proceed(request).onEach { maybeWriteToCache(request, it, customScalarAdapters) - }.single() - - return networkResponse.newBuilder() - .cacheInfo( - CacheInfo.Builder() - .networkStartMillis(startMillis) - .networkEndMillis(currentTimeMillis()) - .build() - ).build() + }.map { networkResponse -> + networkResponse.newBuilder() + .cacheInfo( + CacheInfo.Builder() + .networkStartMillis(startMillis) + .networkEndMillis(currentTimeMillis()) + .build() + ).build() + } } } diff --git a/tests/defer/build.gradle.kts b/tests/defer/build.gradle.kts index e98dba78413..aafec9d7d94 100644 --- a/tests/defer/build.gradle.kts +++ b/tests/defer/build.gradle.kts @@ -17,6 +17,7 @@ kotlin { val commonMain by getting { dependencies { implementation("com.apollographql.apollo3:apollo-runtime") + implementation("com.apollographql.apollo3:apollo-normalized-cache") } } diff --git a/tests/defer/src/commonMain/graphql/operation.graphql b/tests/defer/src/commonMain/graphql/operation.graphql index c77305a3cb0..710be735c82 100644 --- a/tests/defer/src/commonMain/graphql/operation.graphql +++ b/tests/defer/src/commonMain/graphql/operation.graphql @@ -1,9 +1,10 @@ -query WithFragmentSpreads { +query WithFragmentSpreadsQuery { computers { id ...ComputerFields @defer } } + fragment ComputerFields on Computer { cpu year @@ -12,13 +13,13 @@ fragment ComputerFields on Computer { ...ScreenFields @defer(label: "a") } } + fragment ScreenFields on Screen { isColor } - -query WithInlineFragments { +query WithInlineFragmentsQuery { computers { id ... on Computer @defer { @@ -33,3 +34,11 @@ query WithInlineFragments { } } } + + +mutation WithFragmentSpreadsMutation { + computers { + id + ...ComputerFields @defer(label: "c") + } +} diff --git a/tests/defer/src/commonMain/graphql/schema.graphqls b/tests/defer/src/commonMain/graphql/schema.graphqls index 5e7b54e5e5a..65d503ad8a3 100644 --- a/tests/defer/src/commonMain/graphql/schema.graphqls +++ b/tests/defer/src/commonMain/graphql/schema.graphqls @@ -1,12 +1,18 @@ type Query { computers: [Computer!]! } + +type Mutation { + computers: [Computer!]! +} + type Computer { id: ID! cpu: String! year: Int! screen: Screen! } + type Screen { resolution: String! isColor: Boolean! diff --git a/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt new file mode 100644 index 00000000000..4f0050ca119 --- /dev/null +++ b/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt @@ -0,0 +1,462 @@ +package test + +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.annotations.ApolloExperimental +import com.apollographql.apollo3.api.ApolloRequest +import com.apollographql.apollo3.api.ApolloResponse +import com.apollographql.apollo3.api.Error +import com.apollographql.apollo3.api.Operation +import com.apollographql.apollo3.cache.normalized.ApolloStore +import com.apollographql.apollo3.cache.normalized.FetchPolicy +import com.apollographql.apollo3.cache.normalized.api.MemoryCacheFactory +import com.apollographql.apollo3.cache.normalized.fetchPolicy +import com.apollographql.apollo3.cache.normalized.optimisticUpdates +import com.apollographql.apollo3.cache.normalized.store +import com.apollographql.apollo3.exception.ApolloCompositeException +import com.apollographql.apollo3.exception.ApolloException +import com.apollographql.apollo3.exception.ApolloHttpException +import com.apollographql.apollo3.exception.ApolloNetworkException +import com.apollographql.apollo3.exception.CacheMissException +import com.apollographql.apollo3.mockserver.MockResponse +import com.apollographql.apollo3.mockserver.MockServer +import com.apollographql.apollo3.mockserver.enqueueMultipart +import com.apollographql.apollo3.network.NetworkTransport +import com.apollographql.apollo3.testing.runTest +import com.benasher44.uuid.uuid4 +import defer.WithFragmentSpreadsMutation +import defer.WithFragmentSpreadsQuery +import defer.fragment.ComputerFields +import defer.fragment.ScreenFields +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +@OptIn(ApolloExperimental::class) +class DeferNormalizedCacheTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.stop() + apolloClient.dispose() + } + + @Test + fun cacheOnly() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheOnly).build() + + // Cache is empty + assertFailsWith { + apolloClient.query(WithFragmentSpreadsQuery()).execute() + } + + // Fill the cache by doing a network only request + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList) + apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().collect() + mockServer.takeRequest() + + // Cache is not empty, so this doesn't go to the server + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataAssertNoErrors + assertFails { mockServer.takeRequest() } + + // We get the last/fully formed data + val cacheExpected = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false))))) + ) + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun networkOnly() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.NetworkOnly).build() + + // Fill the cache by doing a first request + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList) + apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().collect() + mockServer.takeRequest() + + // Cache is not empty, but NetworkOnly still goes to the server + mockServer.enqueueMultipart(jsonList) + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataAssertNoErrors } + mockServer.takeRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false))))) + ), + ) + assertEquals(networkExpected, networkActual) + } + + @Test + fun cacheFirst() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheFirst).build() + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList) + + // Cache is empty, so this goes to the server + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataAssertNoErrors } + mockServer.takeRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false))))) + ), + ) + assertEquals(networkExpected, networkActual) + + // Cache is not empty, so this doesn't go to the server + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataAssertNoErrors + assertFails { mockServer.takeRequest() } + + // We get the last/fully formed data + val cacheExpected = networkExpected.last() + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun networkFirst() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build() + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList) + + // Cache is empty, so this goes to the server + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataAssertNoErrors } + mockServer.takeRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false))))) + ), + ) + assertEquals(networkExpected, networkActual) + + mockServer.enqueue(MockResponse(500)) + // Network will fail, so we get the cached version + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataAssertNoErrors + + // We get the last/fully formed data + val cacheExpected = networkExpected.last() + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun cacheAndNetwork() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheAndNetwork).build() + + val jsonList1 = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList1) + + // Cache is empty + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataAssertNoErrors } + mockServer.takeRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false))))) + ), + ) + assertEquals(networkExpected, networkActual) + + val jsonList2 = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer2"}]},"hasNext":true}""", + """{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"path":["computers",0],"hasNext":true}""", + """{"data":{"isColor":true},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList2) + + // Cache is not empty + val cacheAndNetworkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataAssertNoErrors } + mockServer.takeRequest() + + // We get a combination of the last/fully formed data from the cache + the new network data + val cacheAndNetworkExpected = listOf( + networkExpected.last(), + + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer2", ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", null)))) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer2", ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true))))) + ), + ) + + assertEquals(cacheAndNetworkExpected, cacheAndNetworkActual) + } + + @Test + fun cacheFirstWithMissingFragmentDueToError() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheFirst).build() + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true}""", + """{"data":null,"path":["computers",0,"screen"],"label":"b","errors":[{"message":"Cannot resolve isColor","locations":[{"line":1,"column":119}],"path":["computers",0,"screen","isColor"]}],"hasNext":false}""", + ) + mockServer.enqueueMultipart(jsonList) + + // Cache is empty, so this goes to the server + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList() + mockServer.takeRequest() + + val query = WithFragmentSpreadsQuery() + val uuid = uuid4() + + val networkExpected = listOf( + ApolloResponse.Builder( + query, + uuid, + data = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ) + ).build(), + + ApolloResponse.Builder( + query, + uuid, + data = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ) + ).build(), + + ApolloResponse.Builder( + query, + uuid, + data = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ) + ) + .errors( + listOf( + Error( + message = "Cannot resolve isColor", + locations = listOf(Error.Location(1, 119)), + path = listOf("computers", 0, "screen", "isColor"), + extensions = null, nonStandardFields = null + ) + ) + ) + .build(), + ) + assertResponseListEquals(networkExpected, networkActual) + + mockServer.enqueue(MockResponse(500)) + // Because of the error the cache is missing some fields, so we get a cache miss, and fallback to the network (which also fails) + val exception = assertFailsWith { + apolloClient.query(WithFragmentSpreadsQuery()).execute().dataAssertNoErrors + } + assertIs(exception.suppressedExceptions.first()) + assertIs(exception.suppressedExceptions.getOrNull(1)) + assertEquals("Object 'computers.0.screen' has no field named 'isColor'", exception.suppressedExceptions.first().message) + mockServer.takeRequest() + } + + @Test + fun networkFirstWithNetworkError() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = WithFragmentSpreadsQuery() + val uuid = uuid4() + val networkResponses = listOf( + ApolloResponse.Builder( + query, + uuid, + data = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ) + ).build(), + + ApolloResponse.Builder( + query, + uuid, + data = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ) + ).build(), + ) + + apolloClient = ApolloClient.Builder() + .store(store) + .fetchPolicy(FetchPolicy.NetworkFirst) + .networkTransport( + object : NetworkTransport { + @Suppress("UNCHECKED_CAST") + override fun execute(request: ApolloRequest): Flow> { + // Emit a few items then an exception + return flow { + for (networkResponse in networkResponses) { + emit(networkResponse as ApolloResponse) + } + delay(10) + throw ApolloNetworkException("Network error") + } + } + + override fun dispose() {} + } + ) + .build() + + // - get the first few responses + // - an exception happens + // - fallback to the cache + // - because of the error the cache is missing some fields, so we get a cache miss + var throwable: Throwable? = null + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow() + .catch { t -> + throwable = t + } + .toList() + + assertResponseListEquals(networkResponses, networkActual) + assertIs(throwable) + throwable as ApolloCompositeException + assertIs(throwable!!.suppressedExceptions.first()) + assertIs(throwable!!.suppressedExceptions.getOrNull(1)) + assertEquals("Object 'computers.0.screen' has no field named 'isColor'", throwable!!.suppressedExceptions.getOrNull(1)!!.message) + } + + @Test + fun mutation() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true,"label":"c"}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList) + val networkActual = apolloClient.mutation(WithFragmentSpreadsMutation()).toFlow().toList().map { it.dataAssertNoErrors } + mockServer.takeRequest() + + val networkExpected = listOf( + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null)))) + ), + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false))))) + ), + ) + assertEquals(networkExpected, networkActual) + + // Now cache is not empty + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().dataAssertNoErrors + + // We get the last/fully formed data + val cacheExpected = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false))))) + ) + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun mutationWithOptimisticDataFails() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"hasNext":true,"label":"c"}""", + """{"data":{"isColor":false},"path":["computers",0,"screen"],"hasNext":false,"label":"a"}""", + ) + mockServer.enqueueMultipart(jsonList) + val responses = apolloClient.mutation(WithFragmentSpreadsMutation()).optimisticUpdates( + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", null)) + ) + ).toFlow() + + val exception = assertFailsWith { + responses.collect() + } + assertEquals("Apollo: optimistic updates can only be applied with one network response", exception.message) + } + +} diff --git a/tests/defer/src/commonTest/kotlin/test/DeferTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferTest.kt index c305ef93300..cea2af5aec5 100644 --- a/tests/defer/src/commonTest/kotlin/test/DeferTest.kt +++ b/tests/defer/src/commonTest/kotlin/test/DeferTest.kt @@ -14,10 +14,7 @@ import defer.fragment.ComputerFields import defer.fragment.ScreenFields import kotlinx.coroutines.flow.toList import kotlin.test.Test -import kotlin.test.assertContentEquals import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull @OptIn(ApolloExperimental::class) class DeferTest { @@ -242,30 +239,3 @@ class DeferTest { assertResponseListEquals(expectedDataList, actualResponseList) } } - -private fun assertResponseListEquals(expectedDataList: List>, actualResponseList: List>) { - assertContentEquals(expectedDataList, actualResponseList) { expectedResponse, actualResponse -> - assertEquals(expectedResponse.data, actualResponse.data) - assertContentEquals(expectedResponse.errors, actualResponse.errors) { expectedError, actualError -> - assertEquals(expectedError.message, actualError.message) - assertContentEquals(expectedError.path, actualError.path) - assertContentEquals(expectedError.locations, actualError.locations) { expectedLocation, actualLocation -> - assertEquals(expectedLocation.line, actualLocation.line) - assertEquals(expectedLocation.column, actualLocation.column) - } - } - } -} - -private fun assertContentEquals(expected: List?, actual: List?, assertEquals: (T, T) -> Unit) { - if (expected == null) { - assertNull(actual) - return - } - assertNotNull(actual) - assertEquals(expected.size, actual.size) - for (i in expected.indices) { - assertEquals(expected[i], actual[i]) - } -} - diff --git a/tests/defer/src/commonTest/kotlin/test/TestUtil.kt b/tests/defer/src/commonTest/kotlin/test/TestUtil.kt new file mode 100644 index 00000000000..a963100d8d5 --- /dev/null +++ b/tests/defer/src/commonTest/kotlin/test/TestUtil.kt @@ -0,0 +1,32 @@ +package test + +import com.apollographql.apollo3.api.ApolloResponse +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal fun assertResponseListEquals(expectedResponseList: List>, actualResponseList: List>) { + assertContentEquals(expectedResponseList, actualResponseList) { expectedResponse, actualResponse -> + assertEquals(expectedResponse.data, actualResponse.data) + assertContentEquals(expectedResponse.errors, actualResponse.errors) { expectedError, actualError -> + assertEquals(expectedError.message, actualError.message) + kotlin.test.assertContentEquals(expectedError.path, actualError.path) + assertContentEquals(expectedError.locations, actualError.locations) { expectedLocation, actualLocation -> + assertEquals(expectedLocation.line, actualLocation.line) + assertEquals(expectedLocation.column, actualLocation.column) + } + } + } +} + +internal fun assertContentEquals(expected: List?, actual: List?, assertEquals: (T, T) -> Unit) { + if (expected == null) { + assertNull(actual) + return + } + assertNotNull(actual) + assertEquals(expected.size, actual.size) + for (i in expected.indices) { + assertEquals(expected[i], actual[i]) + } +}