diff --git a/.github/workflows/defer-with-router-tests.yml b/.github/workflows/defer-integration-tests.yml similarity index 58% rename from .github/workflows/defer-with-router-tests.yml rename to .github/workflows/defer-integration-tests.yml index aa833443d69..16b84fdc9db 100644 --- a/.github/workflows/defer-with-router-tests.yml +++ b/.github/workflows/defer-integration-tests.yml @@ -1,3 +1,5 @@ +name: defer-integration-tests + on: schedule: - cron: '0 3 * * *' @@ -22,9 +24,23 @@ jobs: - run: | ./router --supergraph tests/defer/router/simple-supergraph.graphqls & - - uses: gradle/actions/setup-gradle@dbbdc275be76ac10734476cc723d82dfe7ec6eda #v3.4.2 - - env: DEFER_WITH_ROUTER_TESTS: true run: | ./gradlew --no-daemon --console plain -p tests :defer:allTests + defer-with-apollo-server-tests: + runs-on: ubuntu-latest + if: github.repository == 'apollographql/apollo-kotlin' + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7 + + - working-directory: tests/defer/apollo-server/ + run: | + npm install --legacy-peer-deps + npx patch-package + APOLLO_PORT=4000 npm start & + + - env: + DEFER_WITH_APOLLO_SERVER_TESTS: true + run: | + ./gradlew --no-daemon --console plain -p tests :defer:allTests diff --git a/libraries/apollo-api/api/apollo-api.api b/libraries/apollo-api/api/apollo-api.api index 23317324bb8..a1b27e79109 100644 --- a/libraries/apollo-api/api/apollo-api.api +++ b/libraries/apollo-api/api/apollo-api.api @@ -530,6 +530,7 @@ public final class com/apollographql/apollo/api/DefaultUploadKt { } public final class com/apollographql/apollo/api/DeferredFragmentIdentifier { + public static final field Companion Lcom/apollographql/apollo/api/DeferredFragmentIdentifier$Companion; public fun (Ljava/util/List;Ljava/lang/String;)V public final fun component1 ()Ljava/util/List; public final fun component2 ()Ljava/lang/String; @@ -542,6 +543,10 @@ public final class com/apollographql/apollo/api/DeferredFragmentIdentifier { public fun toString ()Ljava/lang/String; } +public final class com/apollographql/apollo/api/DeferredFragmentIdentifier$Companion { + public final fun getPending ()Lcom/apollographql/apollo/api/DeferredFragmentIdentifier; +} + public final class com/apollographql/apollo/api/EnumType : com/apollographql/apollo/api/CompiledNamedType { public fun (Ljava/lang/String;Ljava/util/List;)V public final fun getValues ()Ljava/util/List; diff --git a/libraries/apollo-api/api/apollo-api.klib.api b/libraries/apollo-api/api/apollo-api.klib.api index 46ddfe5e8c1..2bb22714d6d 100644 --- a/libraries/apollo-api/api/apollo-api.klib.api +++ b/libraries/apollo-api/api/apollo-api.klib.api @@ -994,6 +994,11 @@ final class com.apollographql.apollo.api/DeferredFragmentIdentifier { // com.apo final fun equals(kotlin/Any?): kotlin/Boolean // com.apollographql.apollo.api/DeferredFragmentIdentifier.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // com.apollographql.apollo.api/DeferredFragmentIdentifier.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // com.apollographql.apollo.api/DeferredFragmentIdentifier.toString|toString(){}[0] + + final object Companion { // com.apollographql.apollo.api/DeferredFragmentIdentifier.Companion|null[0] + final val Pending // com.apollographql.apollo.api/DeferredFragmentIdentifier.Companion.Pending|{}Pending[0] + final fun (): com.apollographql.apollo.api/DeferredFragmentIdentifier // com.apollographql.apollo.api/DeferredFragmentIdentifier.Companion.Pending.|(){}[0] + } } final class com.apollographql.apollo.api/EnumType : com.apollographql.apollo.api/CompiledNamedType { // com.apollographql.apollo.api/EnumType|null[0] diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/BooleanExpression.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/BooleanExpression.kt index 464b4833559..c9a1cd0a5b7 100644 --- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/BooleanExpression.kt +++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/BooleanExpression.kt @@ -50,7 +50,8 @@ fun and(vararg other: BooleanExpression): BooleanExpression = Bo fun not(other: BooleanExpression): BooleanExpression = BooleanExpression.Not(other) fun variable(name: String): BooleanExpression = BooleanExpression.Element(BVariable(name)) fun label(label: String? = null): BooleanExpression = BooleanExpression.Element(BLabel(label)) -fun possibleTypes(vararg typenames: String): BooleanExpression = BooleanExpression.Element(BPossibleTypes(typenames.toSet())) +fun possibleTypes(vararg typenames: String): BooleanExpression = + BooleanExpression.Element(BPossibleTypes(typenames.toSet())) internal fun BooleanExpression.evaluate(block: (T) -> Boolean): Boolean { return when (this) { @@ -74,18 +75,29 @@ fun BooleanExpression.evaluate( return evaluate { when (it) { is BVariable -> !(variables?.contains(it.name) ?: false) - is BLabel -> hasDeferredFragment(deferredFragmentIdentifiers, croppedPath!!, it.label) + is BLabel -> shouldParseFragment(deferredFragmentIdentifiers, croppedPath!!, it.label) is BPossibleTypes -> it.possibleTypes.contains(typename) } } } -private fun hasDeferredFragment(deferredFragmentIdentifiers: Set?, path: List, label: String?): Boolean { +private fun shouldParseFragment(deferredFragmentIdentifiers: Set?, path: List, label: String?): Boolean { if (deferredFragmentIdentifiers == null) { // By default, parse all deferred fragments - this is the case when parsing from the normalized cache. return true } - return deferredFragmentIdentifiers.contains(DeferredFragmentIdentifier(path, label)) + val identifier = DeferredFragmentIdentifier(path, label) + return if (deferredFragmentIdentifiers.isPending()) { + // Modern protocol: parse fragments that are _not_ pending + !deferredFragmentIdentifiers.contains(identifier) + } else { + // Legacy GraphQL17Alpha2 protocol: parse fragments that have been merged + deferredFragmentIdentifiers.contains(identifier) + } +} + +private fun Set.isPending(): Boolean { + return any { it === DeferredFragmentIdentifier.Pending } } /** diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/CustomScalarAdapters.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/CustomScalarAdapters.kt index a02faf1ac44..a684756cf4b 100644 --- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/CustomScalarAdapters.kt +++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/CustomScalarAdapters.kt @@ -19,7 +19,7 @@ class CustomScalarAdapters private constructor( @JvmField val falseVariables: Set?, /** - * Defer identifiers used to determine whether the parser must parse @defer fragments + * Identifiers used to determine whether the parser must parse deferred fragments */ @JvmField val deferredFragmentIdentifiers: Set?, diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/DeferredFragmentIdentifier.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/DeferredFragmentIdentifier.kt index 13f5e2d4dac..a71efb5328c 100644 --- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/DeferredFragmentIdentifier.kt +++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/DeferredFragmentIdentifier.kt @@ -1,9 +1,19 @@ package com.apollographql.apollo.api +import com.apollographql.apollo.annotations.ApolloInternal + data class DeferredFragmentIdentifier( /** * Path of the fragment in the overall JSON response. The elements can either be Strings (names) or Integers (array indices). */ val path: List, val label: String?, -) +) { + companion object { + /** + * Special identifier to signal that the identifiers are pending, as in the modern version of the protocol. + */ + @ApolloInternal + val Pending = DeferredFragmentIdentifier(emptyList(), "__pending") + } +} diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/http/DefaultHttpRequestComposer.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/http/DefaultHttpRequestComposer.kt index 9170e69bc59..331198242fd 100644 --- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/http/DefaultHttpRequestComposer.kt +++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/http/DefaultHttpRequestComposer.kt @@ -7,6 +7,7 @@ import com.apollographql.apollo.api.CustomScalarAdapters import com.apollographql.apollo.api.Operation import com.apollographql.apollo.api.Subscription import com.apollographql.apollo.api.Upload +import com.apollographql.apollo.api.http.DefaultHttpRequestComposer.Companion.composePostParams import com.apollographql.apollo.api.http.internal.urlEncode import com.apollographql.apollo.api.json.JsonWriter import com.apollographql.apollo.api.json.buildJsonByteString @@ -39,14 +40,20 @@ class DefaultHttpRequestComposer( val customScalarAdapters = apolloRequest.executionContext[CustomScalarAdapters] ?: CustomScalarAdapters.Empty val requestHeaders = mutableListOf().apply { - if (apolloRequest.operation is Subscription<*>) { - add(HttpHeader(HEADER_ACCEPT_NAME, HEADER_ACCEPT_VALUE_MULTIPART)) - } else { - add(HttpHeader(HEADER_ACCEPT_NAME, HEADER_ACCEPT_VALUE_DEFER)) - } if (apolloRequest.httpHeaders != null) { addAll(apolloRequest.httpHeaders) } + if (get("accept") == null) { + add( + HttpHeader("accept", + if (apolloRequest.operation is Subscription<*>) { + "multipart/mixed;subscriptionSpec=1.0, application/graphql-response+json, application/json" + } else { + "application/graphql-response+json, application/json" + } + ) + ) + } } val sendApqExtensions = apolloRequest.sendApqExtensions ?: false @@ -92,14 +99,18 @@ class DefaultHttpRequestComposer( // and thus is safe to execute. // See https://www.apollographql.com/docs/apollo-server/security/cors/#preventing-cross-site-request-forgery-csrf // for details. - internal val HEADER_APOLLO_REQUIRE_PREFLIGHT = "Apollo-Require-Preflight" + private const val HEADER_APOLLO_REQUIRE_PREFLIGHT = "Apollo-Require-Preflight" + @Deprecated("This was made public by mistake and will be removed in a future version, please use your own constants instead") + @ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v5_0_0) val HEADER_ACCEPT_NAME = "Accept" - // TODO The deferSpec=20220824 part is a temporary measure so early backend implementations of the @defer directive - // can recognize early client implementations and potentially reply in a compatible way. - // This should be removed in later versions. + @Deprecated("This was made public by mistake and will be removed in a future version, please use your own constants instead") + @ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v5_0_0) val HEADER_ACCEPT_VALUE_DEFER = "multipart/mixed;deferSpec=20220824, application/graphql-response+json, application/json" + + @Deprecated("This was made public by mistake and will be removed in a future version, please use your own constants instead") + @ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v5_0_0) val HEADER_ACCEPT_VALUE_MULTIPART = "multipart/mixed;subscriptionSpec=1.0, application/graphql-response+json, application/json" private fun buildGetUrl( diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/internal/ResponseParser.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/internal/ResponseParser.kt index 2ce1c62f70f..f60fa9d5bcc 100644 --- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/internal/ResponseParser.kt +++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo/api/internal/ResponseParser.kt @@ -38,6 +38,7 @@ internal object ResponseParser { val falseVariables = operation.falseVariables(customScalarAdapters) data = operation.parseData(jsonReader, customScalarAdapters, falseVariables, deferredFragmentIds, errors) } + "errors" -> errors = jsonReader.readErrors() "extensions" -> extensions = jsonReader.readAny() as? Map else -> { @@ -100,7 +101,8 @@ private fun JsonReader.readError(): Error { @Suppress("DEPRECATION") - return Error.Builder(message = message).locations(locations).path(path).extensions(extensions).nonStandardFields(nonStandardFields).build() + return Error.Builder(message = message).locations(locations).path(path).extensions(extensions).nonStandardFields(nonStandardFields) + .build() } private fun JsonReader.readPath(): List? { @@ -164,4 +166,4 @@ fun JsonReader.readErrors(): List { } endArray() return list -} \ No newline at end of file +} diff --git a/libraries/apollo-runtime/api/android/apollo-runtime.api b/libraries/apollo-runtime/api/android/apollo-runtime.api index 1eb9c3330bb..9c08f721c49 100644 --- a/libraries/apollo-runtime/api/android/apollo-runtime.api +++ b/libraries/apollo-runtime/api/android/apollo-runtime.api @@ -215,17 +215,6 @@ public final class com/apollographql/apollo/interceptor/RetryOnErrorInterceptorK public static final fun RetryOnErrorInterceptor (Lcom/apollographql/apollo/network/NetworkMonitor;)Lcom/apollographql/apollo/interceptor/ApolloInterceptor; } -public final class com/apollographql/apollo/internal/DeferredJsonMerger { - public fun ()V - public final fun getHasNext ()Z - public final fun getMerged ()Ljava/util/Map; - public final fun getMergedFragmentIds ()Ljava/util/Set; - public final fun isEmptyPayload ()Z - public final fun merge (Ljava/util/Map;)Ljava/util/Map; - public final fun merge (Lokio/BufferedSource;)Ljava/util/Map; - public final fun reset ()V -} - public final class com/apollographql/apollo/internal/MultipartReader : java/io/Closeable { public fun (Lokio/BufferedSource;Ljava/lang/String;)V public fun close ()V @@ -240,6 +229,14 @@ public final class com/apollographql/apollo/internal/MultipartReader$Part : java public final fun getHeaders ()Ljava/util/List; } +public final class com/apollographql/apollo/network/IncrementalDeliveryProtocol : java/lang/Enum { + public static final field GraphQL17Alpha2 Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; + public static final field GraphQL17Alpha9 Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; + public static fun values ()[Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; +} + public abstract interface class com/apollographql/apollo/network/NetworkMonitor : java/io/Closeable { public abstract fun isOnline ()Lkotlinx/coroutines/flow/StateFlow; } @@ -350,7 +347,7 @@ public abstract interface class com/apollographql/apollo/network/http/HttpInterc } public final class com/apollographql/apollo/network/http/HttpNetworkTransport : com/apollographql/apollo/network/NetworkTransport { - public synthetic fun (Lcom/apollographql/apollo/api/http/HttpRequestComposer;Lcom/apollographql/apollo/network/http/HttpEngine;Ljava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/apollographql/apollo/api/http/HttpRequestComposer;Lcom/apollographql/apollo/network/http/HttpEngine;Ljava/util/List;ZLcom/apollographql/apollo/internal/incremental/IncrementalDeliveryProtocolImpl;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun dispose ()V public fun execute (Lcom/apollographql/apollo/api/ApolloRequest;)Lkotlinx/coroutines/flow/Flow; public final fun getInterceptors ()Ljava/util/List; @@ -366,6 +363,7 @@ public final class com/apollographql/apollo/network/http/HttpNetworkTransport$Bu public final fun httpEngine (Lcom/apollographql/apollo/network/http/HttpEngine;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun httpHeaders (Ljava/util/List;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun httpRequestComposer (Lcom/apollographql/apollo/api/http/HttpRequestComposer;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; + public final fun incrementalDeliveryProtocol (Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun interceptors (Ljava/util/List;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun serverUrl (Ljava/lang/String;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; } @@ -546,6 +544,7 @@ public final class com/apollographql/apollo/network/websocket/WebSocketNetworkTr public final fun build ()Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport; public final fun connectionAcknowledgeTimeout-BwNAW2A (Lkotlin/time/Duration;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun idleTimeout-BwNAW2A (Lkotlin/time/Duration;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; + public final fun incrementalDeliveryProtocol (Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun parserFactory (Lcom/apollographql/apollo/network/websocket/SubscriptionParserFactory;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun pingInterval-BwNAW2A (Lkotlin/time/Duration;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun serverUrl (Ljava/lang/String;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; @@ -674,7 +673,7 @@ public final class com/apollographql/apollo/network/ws/WebSocketEngineKt { } public final class com/apollographql/apollo/network/ws/WebSocketNetworkTransport : com/apollographql/apollo/network/NetworkTransport { - public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/util/List;Lcom/apollographql/apollo/network/ws/WebSocketEngine;JLcom/apollographql/apollo/network/ws/WsProtocol$Factory;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/util/List;Lcom/apollographql/apollo/network/ws/WebSocketEngine;JLcom/apollographql/apollo/network/ws/WsProtocol$Factory;Lkotlin/jvm/functions/Function3;Lcom/apollographql/apollo/internal/incremental/IncrementalDeliveryProtocolImpl;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun closeConnection (Ljava/lang/Throwable;)V public fun dispose ()V public fun execute (Lcom/apollographql/apollo/api/ApolloRequest;)Lkotlinx/coroutines/flow/Flow; @@ -688,6 +687,7 @@ public final class com/apollographql/apollo/network/ws/WebSocketNetworkTransport public final fun build ()Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport; public final fun headers (Ljava/util/List;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun idleTimeoutMillis (J)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; + public final fun incrementalDeliveryProtocol (Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun protocol (Lcom/apollographql/apollo/network/ws/WsProtocol$Factory;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun reopenWhen (Lkotlin/jvm/functions/Function3;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun serverUrl (Ljava/lang/String;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; diff --git a/libraries/apollo-runtime/api/apollo-runtime.klib.api b/libraries/apollo-runtime/api/apollo-runtime.klib.api index 29a86706313..0c58f9a6c43 100644 --- a/libraries/apollo-runtime/api/apollo-runtime.klib.api +++ b/libraries/apollo-runtime/api/apollo-runtime.klib.api @@ -21,6 +21,17 @@ final enum class com.apollographql.apollo.network.ws/WsFrameType : kotlin/Enum // com.apollographql.apollo.network.ws/WsFrameType.values|values#static(){}[0] } +final enum class com.apollographql.apollo.network/IncrementalDeliveryProtocol : kotlin/Enum { // com.apollographql.apollo.network/IncrementalDeliveryProtocol|null[0] + enum entry GraphQL17Alpha2 // com.apollographql.apollo.network/IncrementalDeliveryProtocol.GraphQL17Alpha2|null[0] + enum entry GraphQL17Alpha9 // com.apollographql.apollo.network/IncrementalDeliveryProtocol.GraphQL17Alpha9|null[0] + + final val entries // com.apollographql.apollo.network/IncrementalDeliveryProtocol.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.apollographql.apollo.network/IncrementalDeliveryProtocol.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.apollographql.apollo.network/IncrementalDeliveryProtocol // com.apollographql.apollo.network/IncrementalDeliveryProtocol.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.apollographql.apollo.network/IncrementalDeliveryProtocol.values|values#static(){}[0] +} + abstract interface <#A: com.apollographql.apollo.api/Operation.Data> com.apollographql.apollo.network.websocket/SubscriptionParser { // com.apollographql.apollo.network.websocket/SubscriptionParser|null[0] abstract fun parse(kotlin/Any?): com.apollographql.apollo.api/ApolloResponse<#A>? // com.apollographql.apollo.network.websocket/SubscriptionParser.parse|parse(kotlin.Any?){}[0] } @@ -200,24 +211,6 @@ final class com.apollographql.apollo.interceptor/AutoPersistedQueryInterceptor : } } -final class com.apollographql.apollo.internal/DeferredJsonMerger { // com.apollographql.apollo.internal/DeferredJsonMerger|null[0] - constructor () // com.apollographql.apollo.internal/DeferredJsonMerger.|(){}[0] - - final val merged // com.apollographql.apollo.internal/DeferredJsonMerger.merged|{}merged[0] - final fun (): kotlin.collections/Map // com.apollographql.apollo.internal/DeferredJsonMerger.merged.|(){}[0] - final val mergedFragmentIds // com.apollographql.apollo.internal/DeferredJsonMerger.mergedFragmentIds|{}mergedFragmentIds[0] - final fun (): kotlin.collections/Set // com.apollographql.apollo.internal/DeferredJsonMerger.mergedFragmentIds.|(){}[0] - - final var hasNext // com.apollographql.apollo.internal/DeferredJsonMerger.hasNext|{}hasNext[0] - final fun (): kotlin/Boolean // com.apollographql.apollo.internal/DeferredJsonMerger.hasNext.|(){}[0] - final var isEmptyPayload // com.apollographql.apollo.internal/DeferredJsonMerger.isEmptyPayload|{}isEmptyPayload[0] - final fun (): kotlin/Boolean // com.apollographql.apollo.internal/DeferredJsonMerger.isEmptyPayload.|(){}[0] - - final fun merge(kotlin.collections/Map): kotlin.collections/Map // com.apollographql.apollo.internal/DeferredJsonMerger.merge|merge(kotlin.collections.Map){}[0] - final fun merge(okio/BufferedSource): kotlin.collections/Map // com.apollographql.apollo.internal/DeferredJsonMerger.merge|merge(okio.BufferedSource){}[0] - final fun reset() // com.apollographql.apollo.internal/DeferredJsonMerger.reset|reset(){}[0] -} - final class com.apollographql.apollo.internal/MultipartReader : okio/Closeable { // com.apollographql.apollo.internal/MultipartReader|null[0] constructor (okio/BufferedSource, kotlin/String) // com.apollographql.apollo.internal/MultipartReader.|(okio.BufferedSource;kotlin.String){}[0] @@ -322,6 +315,7 @@ final class com.apollographql.apollo.network.http/HttpNetworkTransport : com.apo final fun httpEngine(com.apollographql.apollo.network.http/HttpEngine): com.apollographql.apollo.network.http/HttpNetworkTransport.Builder // com.apollographql.apollo.network.http/HttpNetworkTransport.Builder.httpEngine|httpEngine(com.apollographql.apollo.network.http.HttpEngine){}[0] final fun httpHeaders(kotlin.collections/List): com.apollographql.apollo.network.http/HttpNetworkTransport.Builder // com.apollographql.apollo.network.http/HttpNetworkTransport.Builder.httpHeaders|httpHeaders(kotlin.collections.List){}[0] final fun httpRequestComposer(com.apollographql.apollo.api.http/HttpRequestComposer): com.apollographql.apollo.network.http/HttpNetworkTransport.Builder // com.apollographql.apollo.network.http/HttpNetworkTransport.Builder.httpRequestComposer|httpRequestComposer(com.apollographql.apollo.api.http.HttpRequestComposer){}[0] + final fun incrementalDeliveryProtocol(com.apollographql.apollo.network/IncrementalDeliveryProtocol): com.apollographql.apollo.network.http/HttpNetworkTransport.Builder // com.apollographql.apollo.network.http/HttpNetworkTransport.Builder.incrementalDeliveryProtocol|incrementalDeliveryProtocol(com.apollographql.apollo.network.IncrementalDeliveryProtocol){}[0] final fun interceptors(kotlin.collections/List): com.apollographql.apollo.network.http/HttpNetworkTransport.Builder // com.apollographql.apollo.network.http/HttpNetworkTransport.Builder.interceptors|interceptors(kotlin.collections.List){}[0] final fun serverUrl(kotlin/String): com.apollographql.apollo.network.http/HttpNetworkTransport.Builder // com.apollographql.apollo.network.http/HttpNetworkTransport.Builder.serverUrl|serverUrl(kotlin.String){}[0] } @@ -469,6 +463,7 @@ final class com.apollographql.apollo.network.websocket/WebSocketNetworkTransport final fun build(): com.apollographql.apollo.network.websocket/WebSocketNetworkTransport // com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder.build|build(){}[0] final fun connectionAcknowledgeTimeout(kotlin.time/Duration?): com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder.connectionAcknowledgeTimeout|connectionAcknowledgeTimeout(kotlin.time.Duration?){}[0] final fun idleTimeout(kotlin.time/Duration?): com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder.idleTimeout|idleTimeout(kotlin.time.Duration?){}[0] + final fun incrementalDeliveryProtocol(com.apollographql.apollo.network/IncrementalDeliveryProtocol): com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder.incrementalDeliveryProtocol|incrementalDeliveryProtocol(com.apollographql.apollo.network.IncrementalDeliveryProtocol){}[0] final fun parserFactory(com.apollographql.apollo.network.websocket/SubscriptionParserFactory?): com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder.parserFactory|parserFactory(com.apollographql.apollo.network.websocket.SubscriptionParserFactory?){}[0] final fun pingInterval(kotlin.time/Duration?): com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder.pingInterval|pingInterval(kotlin.time.Duration?){}[0] final fun serverUrl(kotlin/String?): com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.websocket/WebSocketNetworkTransport.Builder.serverUrl|serverUrl(kotlin.String?){}[0] @@ -571,6 +566,7 @@ final class com.apollographql.apollo.network.ws/WebSocketNetworkTransport : com. final fun build(): com.apollographql.apollo.network.ws/WebSocketNetworkTransport // com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder.build|build(){}[0] final fun headers(kotlin.collections/List): com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder.headers|headers(kotlin.collections.List){}[0] final fun idleTimeoutMillis(kotlin/Long): com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder.idleTimeoutMillis|idleTimeoutMillis(kotlin.Long){}[0] + final fun incrementalDeliveryProtocol(com.apollographql.apollo.network/IncrementalDeliveryProtocol): com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder.incrementalDeliveryProtocol|incrementalDeliveryProtocol(com.apollographql.apollo.network.IncrementalDeliveryProtocol){}[0] final fun protocol(com.apollographql.apollo.network.ws/WsProtocol.Factory): com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder.protocol|protocol(com.apollographql.apollo.network.ws.WsProtocol.Factory){}[0] final fun reopenWhen(kotlin.coroutines/SuspendFunction2?): com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder.reopenWhen|reopenWhen(kotlin.coroutines.SuspendFunction2?){}[0] final fun serverUrl(kotlin.coroutines/SuspendFunction0?): com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder // com.apollographql.apollo.network.ws/WebSocketNetworkTransport.Builder.serverUrl|serverUrl(kotlin.coroutines.SuspendFunction0?){}[0] diff --git a/libraries/apollo-runtime/api/jvm/apollo-runtime.api b/libraries/apollo-runtime/api/jvm/apollo-runtime.api index ae89653f943..bab09e9aed9 100644 --- a/libraries/apollo-runtime/api/jvm/apollo-runtime.api +++ b/libraries/apollo-runtime/api/jvm/apollo-runtime.api @@ -215,17 +215,6 @@ public final class com/apollographql/apollo/interceptor/RetryOnErrorInterceptorK public static final fun RetryOnErrorInterceptor (Lcom/apollographql/apollo/network/NetworkMonitor;)Lcom/apollographql/apollo/interceptor/ApolloInterceptor; } -public final class com/apollographql/apollo/internal/DeferredJsonMerger { - public fun ()V - public final fun getHasNext ()Z - public final fun getMerged ()Ljava/util/Map; - public final fun getMergedFragmentIds ()Ljava/util/Set; - public final fun isEmptyPayload ()Z - public final fun merge (Ljava/util/Map;)Ljava/util/Map; - public final fun merge (Lokio/BufferedSource;)Ljava/util/Map; - public final fun reset ()V -} - public final class com/apollographql/apollo/internal/MultipartReader : java/io/Closeable { public fun (Lokio/BufferedSource;Ljava/lang/String;)V public fun close ()V @@ -240,6 +229,14 @@ public final class com/apollographql/apollo/internal/MultipartReader$Part : java public final fun getHeaders ()Ljava/util/List; } +public final class com/apollographql/apollo/network/IncrementalDeliveryProtocol : java/lang/Enum { + public static final field GraphQL17Alpha2 Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; + public static final field GraphQL17Alpha9 Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; + public static fun values ()[Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol; +} + public abstract interface class com/apollographql/apollo/network/NetworkMonitor : java/io/Closeable { public abstract fun isOnline ()Lkotlinx/coroutines/flow/StateFlow; } @@ -346,7 +343,7 @@ public abstract interface class com/apollographql/apollo/network/http/HttpInterc } public final class com/apollographql/apollo/network/http/HttpNetworkTransport : com/apollographql/apollo/network/NetworkTransport { - public synthetic fun (Lcom/apollographql/apollo/api/http/HttpRequestComposer;Lcom/apollographql/apollo/network/http/HttpEngine;Ljava/util/List;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/apollographql/apollo/api/http/HttpRequestComposer;Lcom/apollographql/apollo/network/http/HttpEngine;Ljava/util/List;ZLcom/apollographql/apollo/internal/incremental/IncrementalDeliveryProtocolImpl;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun dispose ()V public fun execute (Lcom/apollographql/apollo/api/ApolloRequest;)Lkotlinx/coroutines/flow/Flow; public final fun getInterceptors ()Ljava/util/List; @@ -362,6 +359,7 @@ public final class com/apollographql/apollo/network/http/HttpNetworkTransport$Bu public final fun httpEngine (Lcom/apollographql/apollo/network/http/HttpEngine;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun httpHeaders (Ljava/util/List;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun httpRequestComposer (Lcom/apollographql/apollo/api/http/HttpRequestComposer;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; + public final fun incrementalDeliveryProtocol (Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun interceptors (Ljava/util/List;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; public final fun serverUrl (Ljava/lang/String;)Lcom/apollographql/apollo/network/http/HttpNetworkTransport$Builder; } @@ -542,6 +540,7 @@ public final class com/apollographql/apollo/network/websocket/WebSocketNetworkTr public final fun build ()Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport; public final fun connectionAcknowledgeTimeout-BwNAW2A (Lkotlin/time/Duration;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun idleTimeout-BwNAW2A (Lkotlin/time/Duration;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; + public final fun incrementalDeliveryProtocol (Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun parserFactory (Lcom/apollographql/apollo/network/websocket/SubscriptionParserFactory;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun pingInterval-BwNAW2A (Lkotlin/time/Duration;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; public final fun serverUrl (Ljava/lang/String;)Lcom/apollographql/apollo/network/websocket/WebSocketNetworkTransport$Builder; @@ -670,7 +669,7 @@ public final class com/apollographql/apollo/network/ws/WebSocketEngineKt { } public final class com/apollographql/apollo/network/ws/WebSocketNetworkTransport : com/apollographql/apollo/network/NetworkTransport { - public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/util/List;Lcom/apollographql/apollo/network/ws/WebSocketEngine;JLcom/apollographql/apollo/network/ws/WsProtocol$Factory;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/util/List;Lcom/apollographql/apollo/network/ws/WebSocketEngine;JLcom/apollographql/apollo/network/ws/WsProtocol$Factory;Lkotlin/jvm/functions/Function3;Lcom/apollographql/apollo/internal/incremental/IncrementalDeliveryProtocolImpl;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun closeConnection (Ljava/lang/Throwable;)V public fun dispose ()V public fun execute (Lcom/apollographql/apollo/api/ApolloRequest;)Lkotlinx/coroutines/flow/Flow; @@ -684,6 +683,7 @@ public final class com/apollographql/apollo/network/ws/WebSocketNetworkTransport public final fun build ()Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport; public final fun headers (Ljava/util/List;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun idleTimeoutMillis (J)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; + public final fun incrementalDeliveryProtocol (Lcom/apollographql/apollo/network/IncrementalDeliveryProtocol;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun protocol (Lcom/apollographql/apollo/network/ws/WsProtocol$Factory;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun reopenWhen (Lkotlin/jvm/functions/Function3;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; public final fun serverUrl (Ljava/lang/String;)Lcom/apollographql/apollo/network/ws/WebSocketNetworkTransport$Builder; diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/DeferredJsonMerger.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/DeferredJsonMerger.kt deleted file mode 100644 index b034caecc28..00000000000 --- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/DeferredJsonMerger.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.apollographql.apollo.internal - -import com.apollographql.apollo.annotations.ApolloInternal -import com.apollographql.apollo.api.DeferredFragmentIdentifier -import com.apollographql.apollo.api.json.BufferedSourceJsonReader -import com.apollographql.apollo.api.json.readAny -import okio.BufferedSource - -private typealias JsonMap = Map -private typealias MutableJsonMap = MutableMap - -/** - * Utility class for merging GraphQL JSON payloads received in multiple chunks when using the `@defer` directive. - * - * Each call to [merge] will merge the given chunk into the [merged] Map, and will also update the [mergedFragmentIds] Set with the - * value of its `path` and `label` field. - * - * The fields in `data` are merged into the node found in [merged] at `path` (for the first call to [merge], the payload is - * copied to [merged] as-is). - * - * `errors` in incremental items (if present) are merged together in an array and then set to the `errors` field of the [merged] Map, - * at each call to [merge]. - * `extensions` in incremental items (if present) are merged together in an array and then set to the `extensions/incremental` field of the - * [merged] Map, at each call to [merge]. - */ -@ApolloInternal -class DeferredJsonMerger { - private val _merged: MutableJsonMap = mutableMapOf() - val merged: JsonMap = _merged - - private val _mergedFragmentIds = mutableSetOf() - val mergedFragmentIds: Set = _mergedFragmentIds - - var hasNext: Boolean = true - private set - - /** - * A payload can sometimes have no `incremental` field, e.g. when the server couldn't predict if there were more data after the last - * emitted payload. This field allows to test for this in order to ignore such payloads. - * See https://github.com/apollographql/router/issues/1687. - */ - var isEmptyPayload: Boolean = false - private set - - fun merge(payload: BufferedSource): JsonMap { - val payloadMap = jsonToMap(payload) - return merge(payloadMap) - } - - @Suppress("UNCHECKED_CAST") - fun merge(payload: JsonMap): JsonMap { - if (merged.isEmpty()) { - // Initial payload, no merging needed - _merged += payload - return merged - } - - val incrementalList = payload["incremental"] as? List - if (incrementalList == null) { - isEmptyPayload = true - } else { - isEmptyPayload = false - val mergedErrors = mutableListOf() - val mergedExtensions = mutableListOf() - for (incrementalItem in incrementalList) { - mergeData(incrementalItem) - // Merge errors and extensions (if any) of the incremental list - (incrementalItem["errors"] as? List)?.let { mergedErrors += it } - (incrementalItem["extensions"] as? JsonMap)?.let { mergedExtensions += it } - } - // Keep only this payload's errors and extensions, if any - if (mergedErrors.isNotEmpty()) { - _merged["errors"] = mergedErrors - } else { - _merged.remove("errors") - } - if (mergedExtensions.isNotEmpty()) { - _merged["extensions"] = mapOf("incremental" to mergedExtensions) - } else { - _merged.remove("extensions") - } - } - - hasNext = payload["hasNext"] as Boolean? ?: false - - return merged - } - - @Suppress("UNCHECKED_CAST") - private fun mergeData(incrementalItem: JsonMap) { - val data = incrementalItem["data"] as JsonMap? - val path = incrementalItem["path"] as List - val mergedData = merged["data"] as JsonMap - - // payloadData can be null if there are errors - if (data != null) { - val nodeToMergeInto = nodeAtPath(mergedData, path) as MutableJsonMap - deepMerge(nodeToMergeInto, data) - - _mergedFragmentIds += DeferredFragmentIdentifier(path = path, label = incrementalItem["label"] as String?) - } - } - - @Suppress("UNCHECKED_CAST") - private fun deepMerge(destination: MutableJsonMap, map: JsonMap) { - for ((key, value) in map) { - if (destination.containsKey(key) && destination[key] is MutableMap<*, *>) { - // Objects: merge recursively - val fieldDestination = destination[key] as MutableJsonMap - val fieldMap = value as? JsonMap ?: error("'$key' is an object in destination but not in map") - deepMerge(destination = fieldDestination, map = fieldMap) - } else { - // Other types: add / overwrite - destination[key] = value - } - } - } - - @Suppress("UNCHECKED_CAST") - private fun jsonToMap(json: BufferedSource): JsonMap = BufferedSourceJsonReader(json).readAny() as JsonMap - - - /** - * Find the node in the [map] at the given [path]. - * @param path The path to the node to find, as a list of either `String` (name of field in object) or `Int` (index of element in array). - */ - private fun nodeAtPath(map: JsonMap, path: List): Any? { - var node: Any? = map - for (key in path) { - node = if (node is List<*>) { - node[key as Int] - } else { - @Suppress("UNCHECKED_CAST") - node as JsonMap - node[key] - } - } - return node - } - - fun reset() { - _merged.clear() - _mergedFragmentIds.clear() - hasNext = true - isEmptyPayload = false - } -} - -internal fun JsonMap.isDeferred(): Boolean { - return keys.contains("hasNext") -} diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/GraphQL17Alpha2IncrementalResultsMerger.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/GraphQL17Alpha2IncrementalResultsMerger.kt new file mode 100644 index 00000000000..b741a6df394 --- /dev/null +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/GraphQL17Alpha2IncrementalResultsMerger.kt @@ -0,0 +1,89 @@ +package com.apollographql.apollo.internal.incremental + +import com.apollographql.apollo.api.DeferredFragmentIdentifier +import okio.BufferedSource + +/** + * Merger for the [com.apollographql.apollo.network.IncrementalDeliveryProtocol.GraphQL17Alpha2] protocol format. + */ +@Suppress("UNCHECKED_CAST") +internal class GraphQL17Alpha2IncrementalResultsMerger : IncrementalResultsMerger { + private val _merged: MutableJsonMap = mutableMapOf() + override val merged: JsonMap = _merged + + private val _deferredFragmentIdentifiers = mutableSetOf() + + /** + * For this protocol, this represents the set of fragment ids that are already merged. + */ + override val deferredFragmentIdentifiers: Set = _deferredFragmentIdentifiers + + override var hasNext: Boolean = true + private set + + override var isEmptyResponse: Boolean = false + private set + + override fun merge(part: BufferedSource): JsonMap { + return merge(part.toJsonMap()) + } + + override fun merge(part: JsonMap): JsonMap { + if (merged.isEmpty()) { + // Initial part, no merging needed + _merged += part + return merged + } + + val incremental = part["incremental"] as? List + if (incremental == null) { + isEmptyResponse = true + } else { + isEmptyResponse = false + val mergedErrors = mutableListOf() + val mergedExtensions = mutableListOf() + for (incrementalResult in incremental) { + incrementalResult(incrementalResult) + // Merge errors and extensions (if any) of the incremental result + (incrementalResult["errors"] as? List)?.let { mergedErrors += it } + (incrementalResult["extensions"] as? JsonMap)?.let { mergedExtensions += it } + } + // Keep only this payload's errors and extensions, if any + if (mergedErrors.isNotEmpty()) { + _merged["errors"] = mergedErrors + } else { + _merged.remove("errors") + } + if (mergedExtensions.isNotEmpty()) { + _merged["extensions"] = mapOf("incremental" to mergedExtensions) + } else { + _merged.remove("extensions") + } + } + + hasNext = part["hasNext"] as Boolean? ?: false + + return merged + } + + private fun incrementalResult(incrementalResult: JsonMap) { + val data = incrementalResult["data"] as JsonMap? + val path = incrementalResult["path"] as List + val mergedData = merged["data"] as JsonMap + + // data can be null if there are errors + if (data != null) { + val nodeToMergeInto = nodeAtPath(mergedData, path) as MutableJsonMap + deepMergeObject(nodeToMergeInto, data) + + _deferredFragmentIdentifiers += DeferredFragmentIdentifier(path = path, label = incrementalResult["label"] as String?) + } + } + + override fun reset() { + _merged.clear() + _deferredFragmentIdentifiers.clear() + hasNext = true + isEmptyResponse = false + } +} diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/GraphQL17Alpha9IncrementalResultsMerger.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/GraphQL17Alpha9IncrementalResultsMerger.kt new file mode 100644 index 00000000000..07952b1d227 --- /dev/null +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/GraphQL17Alpha9IncrementalResultsMerger.kt @@ -0,0 +1,129 @@ +package com.apollographql.apollo.internal.incremental + +import com.apollographql.apollo.api.DeferredFragmentIdentifier +import okio.BufferedSource + +/** + * Merger for the [com.apollographql.apollo.network.IncrementalDeliveryProtocol.GraphQL17Alpha9] protocol format. + */ +@Suppress("UNCHECKED_CAST") +internal class GraphQL17Alpha9IncrementalResultsMerger : IncrementalResultsMerger { + private val _merged: MutableJsonMap = mutableMapOf() + override val merged: JsonMap = _merged + + /** + * Map of identifiers to their corresponding IncrementalResultIdentifier, found in `pending`. + */ + private val _pendingResultIds = mutableMapOf() + + /** + * For this protocol, this represents the set of ids that are pending. + */ + override val deferredFragmentIdentifiers: Set get() = _pendingResultIds.values.toSet() + DeferredFragmentIdentifier.Pending + + override var hasNext: Boolean = true + private set + + override var isEmptyResponse: Boolean = false + private set + + override fun merge(part: BufferedSource): JsonMap { + return merge(part.toJsonMap()) + } + + override fun merge(part: JsonMap): JsonMap { + val completed = part["completed"] as? List + if (merged.isEmpty()) { + // Initial part, no merging needed (strip some fields that should not appear in the final result) + _merged += part - "hasNext" - "pending" + handlePending(part) + handleCompleted(completed) + return merged + } + handlePending(part) + + val incremental = part["incremental"] as? List + if (incremental != null) { + for (incrementalResult in incremental) { + mergeIncrementalResult(incrementalResult) + // Merge errors (if any) of the incremental result + (incrementalResult["errors"] as? List)?.let { getOrPutMergedErrors() += it } + } + } + isEmptyResponse = completed == null && incremental == null + + hasNext = part["hasNext"] as Boolean? ?: false + + handleCompleted(completed) + + (part["extensions"] as? JsonMap)?.let { getOrPutExtensions() += it } + + return merged + } + + private fun getOrPutMergedErrors() = _merged.getOrPut("errors") { mutableListOf() } as MutableList + + private fun getOrPutExtensions() = _merged.getOrPut("extensions") { mutableMapOf() } as MutableJsonMap + + private fun handlePending(part: JsonMap) { + val pending = part["pending"] as? List + if (pending != null) { + for (pendingResult in pending) { + val id = pendingResult["id"] as String + val path = pendingResult["path"] as List + val label = pendingResult["label"] as String? + _pendingResultIds[id] = DeferredFragmentIdentifier(path = path, label = label) + } + } + } + + private fun handleCompleted(completed: List?) { + if (completed != null) { + for (completedResult in completed) { + // Merge errors (if any) of the completed result + val errors = completedResult["errors"] as? List + if (errors != null) { + getOrPutMergedErrors() += errors + } else { + // Fragment is no longer pending - only if there were no errors + val id = completedResult["id"] as String + _pendingResultIds.remove(id) ?: error("Id '$id' not found in pending results") + } + } + } + } + + private fun mergeIncrementalResult(incrementalResult: JsonMap) { + val id = incrementalResult["id"] as String? ?: error("No id found in incremental result") + val data = incrementalResult["data"] as JsonMap? + val items = incrementalResult["items"] as List? + val subPath = incrementalResult["subPath"] as List? ?: emptyList() + val path = (_pendingResultIds[id]?.path ?: error("Id '$id' not found in pending results")) + subPath + val mergedData = merged["data"] as JsonMap + val nodeToMergeInto = nodeAtPath(mergedData, path) + when { + data != null -> { + deepMergeObject(nodeToMergeInto as MutableJsonMap, data) + } + + items != null -> { + mergeList(nodeToMergeInto as MutableList, items) + } + + else -> { + error("Neither data nor items found in incremental result") + } + } + } + + private fun mergeList(destination: MutableList, items: List) { + destination.addAll(items) + } + + override fun reset() { + _merged.clear() + _pendingResultIds.clear() + hasNext = true + isEmptyResponse = false + } +} diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/IncrementalDeliveryProtocolImpl.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/IncrementalDeliveryProtocolImpl.kt new file mode 100644 index 00000000000..3bc8da45bfa --- /dev/null +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/IncrementalDeliveryProtocolImpl.kt @@ -0,0 +1,29 @@ +package com.apollographql.apollo.internal.incremental + +import com.apollographql.apollo.network.IncrementalDeliveryProtocol + +internal sealed interface IncrementalDeliveryProtocolImpl { + val acceptHeader: String + + fun newIncrementalResultsMerger(): IncrementalResultsMerger + + object GraphQL17Alpha2 : IncrementalDeliveryProtocolImpl { + override val acceptHeader: String = "multipart/mixed;deferSpec=20220824, application/graphql-response+json, application/json" + + override fun newIncrementalResultsMerger(): IncrementalResultsMerger = GraphQL17Alpha2IncrementalResultsMerger() + } + + object GraphQL17Alpha9 : IncrementalDeliveryProtocolImpl { + // TODO To be agreed upon with the router and other clients + override val acceptHeader: String = + "multipart/mixed;incrementalDeliverySpec=20230621, application/graphql-response+json, application/json" + + override fun newIncrementalResultsMerger(): IncrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + } +} + +internal val IncrementalDeliveryProtocol.impl: IncrementalDeliveryProtocolImpl + get() = when (this) { + IncrementalDeliveryProtocol.GraphQL17Alpha2 -> IncrementalDeliveryProtocolImpl.GraphQL17Alpha2 + IncrementalDeliveryProtocol.GraphQL17Alpha9 -> IncrementalDeliveryProtocolImpl.GraphQL17Alpha9 + } diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/IncrementalResultsMerger.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/IncrementalResultsMerger.kt new file mode 100644 index 00000000000..5577849e335 --- /dev/null +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/IncrementalResultsMerger.kt @@ -0,0 +1,39 @@ +package com.apollographql.apollo.internal.incremental + +import com.apollographql.apollo.api.DeferredFragmentIdentifier +import okio.BufferedSource + +/** + * Utility for merging GraphQL incremental results received in multiple chunks when using the `@defer` and/or `@stream` directives. + * + * Each call to [merge] will merge the given results into the [merged] Map, and will also update [deferredFragmentIdentifiers] with the + * value of their `path` and `label` fields. + * + * The fields in `data` are merged into the node found in [merged] at the path known by looking at the `id` field. For the first call to + * [merge], the payload is copied to [merged] as-is. + * + * `errors` in incremental and completed results (if present) are merged together in an array and then set to the `errors` field of the + * [merged] Map. + * `extensions` in incremental results (if present) are merged together in an array and then set to the `extensions` field of the [merged] + * Map. + */ +internal sealed interface IncrementalResultsMerger { + val merged: JsonMap + + val deferredFragmentIdentifiers: Set + + val hasNext: Boolean + + /** + * A response can sometimes have no `incremental` field, e.g. when the server couldn't predict if there were more data after the last + * emitted payload. This field allows to test for this in order to ignore such payloads. + * See https://github.com/apollographql/router/issues/1687. + */ + val isEmptyResponse: Boolean + + fun merge(part: BufferedSource): JsonMap + + fun merge(part: JsonMap): JsonMap + + fun reset() +} diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/JsonMap.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/JsonMap.kt new file mode 100644 index 00000000000..3d9d62ac17e --- /dev/null +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/internal/incremental/JsonMap.kt @@ -0,0 +1,44 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.apollographql.apollo.internal.incremental + +import com.apollographql.apollo.api.json.BufferedSourceJsonReader +import com.apollographql.apollo.api.json.readAny +import okio.BufferedSource + +internal typealias JsonMap = Map +internal typealias MutableJsonMap = MutableMap + +/** + * Find the node in the [map] at the given [path]. + * @param path The path to the node to find, as a list of either `String` (name of field in object) or `Int` (index of element in array). + */ +internal fun nodeAtPath(map: JsonMap, path: List): Any? { + var node: Any? = map + for (key in path) { + node = if (node is List<*>) { + node[key as Int] + } else { + node as JsonMap + node[key] + } + } + return node +} + +internal fun deepMergeObject(destination: MutableJsonMap, obj: JsonMap) { + for ((key, value) in obj) { + if (destination.containsKey(key) && destination[key] is MutableMap<*, *>) { + // Objects: merge recursively + val fieldDestination = destination[key] as MutableJsonMap + val fieldMap = value as? JsonMap ?: error("'$key' is an object in destination but not in map") + deepMergeObject(destination = fieldDestination, obj = fieldMap) + } else { + // Other types: add / overwrite + destination[key] = value + } + } +} + +internal fun BufferedSource.toJsonMap(): JsonMap = BufferedSourceJsonReader(this).readAny() as JsonMap + diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/IncrementalDeliveryProtocol.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/IncrementalDeliveryProtocol.kt new file mode 100644 index 00000000000..f01d205a1b6 --- /dev/null +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/IncrementalDeliveryProtocol.kt @@ -0,0 +1,27 @@ +package com.apollographql.apollo.network + +import com.apollographql.apollo.annotations.ApolloExperimental + +/** + * The protocol to use for incremental delivery (`@defer` and `@stream`). + */ +@ApolloExperimental +enum class IncrementalDeliveryProtocol { + + /** + * Newer format as implemented by graphql.js version `17.0.0-alpha.2` and specified in this historical commit: + * https://github.com/graphql/graphql-spec/tree/48cf7263a71a683fab03d45d309fd42d8d9a6659/spec + * + * Only `@defer` is supported with this format. + * + * This is the default. + */ + GraphQL17Alpha2, + + /** + * Newer format as implemented by graphql.js version `17.0.0-alpha.9`. + * + * Both `@defer` and `@stream` are supported with this format. + */ + GraphQL17Alpha9 +} diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/http/HttpNetworkTransport.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/http/HttpNetworkTransport.kt index b40b74a53c8..f15c866dea6 100644 --- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/http/HttpNetworkTransport.kt +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/http/HttpNetworkTransport.kt @@ -1,6 +1,7 @@ package com.apollographql.apollo.network.http import com.apollographql.apollo.annotations.ApolloDeprecatedSince +import com.apollographql.apollo.annotations.ApolloExperimental import com.apollographql.apollo.api.ApolloRequest import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.CustomScalarAdapters @@ -21,11 +22,14 @@ import com.apollographql.apollo.exception.ApolloException import com.apollographql.apollo.exception.ApolloHttpException import com.apollographql.apollo.exception.ApolloNetworkException import com.apollographql.apollo.exception.RouterError -import com.apollographql.apollo.internal.DeferredJsonMerger +import com.apollographql.apollo.internal.incremental.IncrementalDeliveryProtocolImpl +import com.apollographql.apollo.internal.incremental.IncrementalResultsMerger +import com.apollographql.apollo.internal.incremental.impl import com.apollographql.apollo.internal.isGraphQLResponse import com.apollographql.apollo.internal.isMultipart import com.apollographql.apollo.internal.multipartBodyFlow import com.apollographql.apollo.mpp.currentTimeMillis +import com.apollographql.apollo.network.IncrementalDeliveryProtocol import com.apollographql.apollo.network.NetworkTransport import com.benasher44.uuid.Uuid import com.benasher44.uuid.uuid4 @@ -44,6 +48,7 @@ private constructor( private val engine: HttpEngine, val interceptors: List, private val exposeErrorBody: Boolean, + private val incrementalDeliveryProtocolImpl: IncrementalDeliveryProtocolImpl, ) : NetworkTransport { private val engineInterceptor = EngineInterceptor() @@ -51,6 +56,17 @@ private constructor( request: ApolloRequest, ): Flow> { val customScalarAdapters = request.executionContext[CustomScalarAdapters]!! + + val request = if (request.httpHeaders.orEmpty().none { it.name.lowercase() == "accept" }) { + val accept = if (request.operation is Subscription<*>) { + "multipart/mixed;subscriptionSpec=1.0, application/graphql-response+json, application/json" + } else { + incrementalDeliveryProtocolImpl.acceptHeader + } + request.newBuilder().addHttpHeader("accept", accept).build() + } else { + request + } val httpRequest = httpRequestComposer.compose(request) return execute(request, httpRequest, customScalarAdapters) @@ -157,10 +173,10 @@ private constructor( val response = httpResponse.body!!.jsonReader() .apply { ignoreUnknownKeys(request.ignoreUnknownKeys ?: true) } .toApolloResponse( - operation, - customScalarAdapters = customScalarAdapters, - deferredFragmentIdentifiers = null, - ) + operation, + customScalarAdapters = customScalarAdapters, + deferredFragmentIdentifiers = null, + ) return flowOf(response.newBuilder().isLast(true).build()) } @@ -170,7 +186,7 @@ private constructor( customScalarAdapters: CustomScalarAdapters, httpResponse: HttpResponse, ): Flow> { - var jsonMerger: DeferredJsonMerger? = null + var incrementalResultsMerger: IncrementalResultsMerger? = null val operation = request.operation return multipartBodyFlow(httpResponse) @@ -218,21 +234,20 @@ private constructor( else -> null } } else { - if (jsonMerger == null) { - jsonMerger = DeferredJsonMerger() + if (incrementalResultsMerger == null) { + incrementalResultsMerger = incrementalDeliveryProtocolImpl.newIncrementalResultsMerger() } - val merged = jsonMerger.merge(part) - val deferredFragmentIds = jsonMerger.mergedFragmentIds - val isLast = !jsonMerger.hasNext + val merged = incrementalResultsMerger.merge(part) + val deferredFragmentIdentifiers = incrementalResultsMerger.deferredFragmentIdentifiers + val isLast = !incrementalResultsMerger.hasNext - if (jsonMerger.isEmptyPayload) { + if (incrementalResultsMerger.isEmptyResponse) { null } else { - @Suppress("DEPRECATION") merged.jsonReader().toApolloResponse( operation = operation, customScalarAdapters = customScalarAdapters, - deferredFragmentIdentifiers = deferredFragmentIds + deferredFragmentIdentifiers = deferredFragmentIdentifiers ).newBuilder().isLast(isLast).build() } } @@ -250,8 +265,8 @@ private constructor( this } else { ApolloNetworkException( - message = "Error while reading response", - platformCause = this + message = "Error while reading response", + platformCause = this ) } } @@ -311,6 +326,7 @@ private constructor( private var engine: HttpEngine? = null private val interceptors: MutableList = mutableListOf() private var exposeErrorBody: Boolean = false + private var incrementalDeliveryProtocol: IncrementalDeliveryProtocol = IncrementalDeliveryProtocol.GraphQL17Alpha2 private val headers: MutableList = mutableListOf() fun httpRequestComposer(httpRequestComposer: HttpRequestComposer) = apply { @@ -354,6 +370,16 @@ private constructor( this.engine = httpEngine } + /** + * The incremental delivery protocol to use when using `@defer` and/or `@stream`. + * + * Default: [IncrementalDeliveryProtocol.GraphQL17Alpha2] + */ + @ApolloExperimental + fun incrementalDeliveryProtocol(incrementalDeliveryProtocol: IncrementalDeliveryProtocol) = apply { + this.incrementalDeliveryProtocol = incrementalDeliveryProtocol + } + fun interceptors(interceptors: List) = apply { this.interceptors.clear() this.interceptors.addAll(interceptors) @@ -380,6 +406,7 @@ private constructor( engine = engine ?: DefaultHttpEngine(), interceptors = interceptors, exposeErrorBody = exposeErrorBody, + incrementalDeliveryProtocolImpl = incrementalDeliveryProtocol.impl, ) } } diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/websocket/WebSocketNetworkTransport.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/websocket/WebSocketNetworkTransport.kt index 7dec7222547..907fa2bc949 100644 --- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/websocket/WebSocketNetworkTransport.kt +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/websocket/WebSocketNetworkTransport.kt @@ -12,7 +12,9 @@ import com.apollographql.apollo.exception.ApolloException import com.apollographql.apollo.exception.ApolloWebSocketForceCloseException import com.apollographql.apollo.exception.DefaultApolloException import com.apollographql.apollo.exception.SubscriptionOperationException -import com.apollographql.apollo.internal.DeferredJsonMerger +import com.apollographql.apollo.internal.incremental.IncrementalDeliveryProtocolImpl +import com.apollographql.apollo.internal.incremental.impl +import com.apollographql.apollo.network.IncrementalDeliveryProtocol import com.apollographql.apollo.network.NetworkTransport import com.apollographql.apollo.network.websocket.internal.OperationListener import com.apollographql.apollo.network.websocket.internal.WebSocketPool @@ -41,7 +43,7 @@ class WebSocketNetworkTransport private constructor( private val connectionAcknowledgeTimeout: Duration, private val pingInterval: Duration?, private val idleTimeout: Duration, - private val parserFactory: SubscriptionParserFactory + private val parserFactory: SubscriptionParserFactory, ) : NetworkTransport { private val pool = WebSocketPool( @@ -114,6 +116,7 @@ class WebSocketNetworkTransport private constructor( private var pingInterval: Duration? = null private var idleTimeout: Duration? = null private var parserFactory: SubscriptionParserFactory? = null + private var incrementalDeliveryProtocol: IncrementalDeliveryProtocol = IncrementalDeliveryProtocol.GraphQL17Alpha2 /** * @param serverUrl a server url that is called every time a WebSocket @@ -177,6 +180,15 @@ class WebSocketNetworkTransport private constructor( this.parserFactory = parserFactory } + /** + * The incremental delivery protocol to use when using `@defer` and/or `@stream`. + * + * Default: [IncrementalDeliveryProtocol.GraphQL17Alpha2] + */ + @ApolloExperimental + fun incrementalDeliveryProtocol(incrementalDeliveryProtocol: IncrementalDeliveryProtocol) = apply { + this.incrementalDeliveryProtocol = incrementalDeliveryProtocol + } /** * Builds the [WebSocketNetworkTransport] @@ -189,20 +201,25 @@ class WebSocketNetworkTransport private constructor( wsProtocol = wsProtocol ?: GraphQLWsProtocol { null }, pingInterval = pingInterval, connectionAcknowledgeTimeout = connectionAcknowledgeTimeout ?: 10.seconds, - parserFactory = parserFactory ?: DefaultSubscriptionParserFactory + parserFactory = parserFactory ?: DefaultSubscriptionParserFactory(incrementalDeliveryProtocol.impl), ) } } } -private object DefaultSubscriptionParserFactory: SubscriptionParserFactory { +private class DefaultSubscriptionParserFactory( + private val incrementalDeliveryProtocolImpl: IncrementalDeliveryProtocolImpl, +) : SubscriptionParserFactory { override fun createParser(request: ApolloRequest): SubscriptionParser { - return DefaultSubscriptionParser(request) + return DefaultSubscriptionParser(incrementalDeliveryProtocolImpl, request) } } -private class DefaultSubscriptionParser(private val request: ApolloRequest) : SubscriptionParser { - private var deferredJsonMerger: DeferredJsonMerger = DeferredJsonMerger() +private class DefaultSubscriptionParser( + incrementalDeliveryProtocolImpl: IncrementalDeliveryProtocolImpl, + private val request: ApolloRequest, +) : SubscriptionParser { + private val incrementalResultsMerger = incrementalDeliveryProtocolImpl.newIncrementalResultsMerger() private val requestCustomScalarAdapters = request.executionContext[CustomScalarAdapters] ?: CustomScalarAdapters.Empty @Suppress("NAME_SHADOWING") @@ -214,8 +231,8 @@ private class DefaultSubscriptionParser(private val request: .exception(DefaultApolloException("Invalid payload")).build() } - val (payload, mergedFragmentIds) = if (responseMap.isDeferred()) { - deferredJsonMerger.merge(responseMap) to deferredJsonMerger.mergedFragmentIds + val (payload, deferredFragmentIdentifiers) = if (responseMap.isDeferred()) { + incrementalResultsMerger.merge(responseMap) to incrementalResultsMerger.deferredFragmentIdentifiers } else { responseMap to null } @@ -223,15 +240,15 @@ private class DefaultSubscriptionParser(private val request: operation = request.operation, requestUuid = request.requestUuid, customScalarAdapters = requestCustomScalarAdapters, - deferredFragmentIdentifiers = mergedFragmentIds + deferredFragmentIdentifiers = deferredFragmentIdentifiers, ) - if (!deferredJsonMerger.hasNext) { - // Last deferred payload: reset the deferredJsonMerger for potential subsequent responses - deferredJsonMerger.reset() + if (!incrementalResultsMerger.hasNext) { + // Last deferred payload: reset the incrementalResultsMerger for potential subsequent responses + incrementalResultsMerger.reset() } - return if (deferredJsonMerger.isEmptyPayload) { + return if (incrementalResultsMerger.isEmptyResponse) { null } else { apolloResponse @@ -242,7 +259,7 @@ private class DefaultSubscriptionParser(private val request: private class DefaultOperationListener( private val request: ApolloRequest, private val producerScope: ProducerScope>, - private val parser: SubscriptionParser + private val parser: SubscriptionParser, ) : OperationListener { override fun onResponse(response: ApolloJsonElement) { parser.parse(response)?.let { @@ -289,7 +306,8 @@ private fun Map.isDeferred(): Boolean { */ @ApolloExperimental fun NetworkTransport.closeConnection(exception: ApolloException) { - val webSocketNetworkTransport = (this as? WebSocketNetworkTransport) ?: throw IllegalArgumentException("'$this' is not an instance of com.apollographql.apollo.websocket.WebSocketNetworkTransport") + val webSocketNetworkTransport = (this as? WebSocketNetworkTransport) + ?: throw IllegalArgumentException("'$this' is not an instance of com.apollographql.apollo.websocket.WebSocketNetworkTransport") webSocketNetworkTransport.closeConnection(exception) } @@ -301,7 +319,8 @@ fun NetworkTransport.closeConnection(exception: ApolloException) { */ @ApolloExperimental fun NetworkTransport.closeConnection() { - val webSocketNetworkTransport = (this as? WebSocketNetworkTransport) ?: throw IllegalArgumentException("'$this' is not an instance of com.apollographql.apollo.websocket.WebSocketNetworkTransport") + val webSocketNetworkTransport = (this as? WebSocketNetworkTransport) + ?: throw IllegalArgumentException("'$this' is not an instance of com.apollographql.apollo.websocket.WebSocketNetworkTransport") webSocketNetworkTransport.closeConnection(ApolloWebSocketForceCloseException) } diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/ws/WebSocketNetworkTransport.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/ws/WebSocketNetworkTransport.kt index 80383932fcc..f9244718878 100644 --- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/ws/WebSocketNetworkTransport.kt +++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo/network/ws/WebSocketNetworkTransport.kt @@ -1,5 +1,6 @@ package com.apollographql.apollo.network.ws +import com.apollographql.apollo.annotations.ApolloExperimental import com.apollographql.apollo.api.ApolloRequest import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.CustomScalarAdapters @@ -10,9 +11,10 @@ import com.apollographql.apollo.api.toApolloResponse import com.apollographql.apollo.exception.ApolloException import com.apollographql.apollo.exception.ApolloNetworkException import com.apollographql.apollo.exception.SubscriptionOperationException -import com.apollographql.apollo.internal.DeferredJsonMerger -import com.apollographql.apollo.internal.isDeferred +import com.apollographql.apollo.internal.incremental.IncrementalDeliveryProtocolImpl +import com.apollographql.apollo.internal.incremental.impl import com.apollographql.apollo.internal.transformWhile +import com.apollographql.apollo.network.IncrementalDeliveryProtocol import com.apollographql.apollo.network.NetworkTransport import com.apollographql.apollo.network.ws.internal.Command import com.apollographql.apollo.network.ws.internal.ConnectionReEstablished @@ -45,7 +47,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch -import okio.use /** * A [NetworkTransport] that manages a single instance of a [WebSocketConnection]. @@ -62,7 +63,8 @@ private constructor( private val webSocketEngine: WebSocketEngine = DefaultWebSocketEngine(), private val idleTimeoutMillis: Long = 60_000, private val protocolFactory: WsProtocol.Factory = SubscriptionWsProtocol.Factory(), - private val reopenWhen: (suspend (Throwable, attempt: Long) -> Boolean)? + private val reopenWhen: (suspend (Throwable, attempt: Long) -> Boolean)?, + private val incrementalDeliveryProtocolImpl: IncrementalDeliveryProtocolImpl, ) : NetworkTransport { /** @@ -263,7 +265,7 @@ private constructor( override fun execute( request: ApolloRequest, ): Flow> { - val deferredJsonMerger = DeferredJsonMerger() + val incrementalResultsMerger = incrementalDeliveryProtocolImpl.newIncrementalResultsMerger() return events.onSubscription { messages.send(StartOperation(request)) @@ -303,8 +305,8 @@ private constructor( is OperationResponse -> { val responsePayload = response.payload val requestCustomScalarAdapters = request.executionContext[CustomScalarAdapters]!! - val (payload, mergedFragmentIds) = if (responsePayload.isDeferred()) { - deferredJsonMerger.merge(responsePayload) to deferredJsonMerger.mergedFragmentIds + val (payload, deferredFragmentIdentifiers) = if (responsePayload.isDeferred()) { + incrementalResultsMerger.merge(responsePayload) to incrementalResultsMerger.deferredFragmentIdentifiers } else { responsePayload to null } @@ -312,12 +314,12 @@ private constructor( operation = request.operation, requestUuid = request.requestUuid, customScalarAdapters = requestCustomScalarAdapters, - deferredFragmentIdentifiers = mergedFragmentIds + deferredFragmentIdentifiers = deferredFragmentIdentifiers ) - if (!deferredJsonMerger.hasNext) { - // Last deferred payload: reset the deferredJsonMerger for potential subsequent responses - deferredJsonMerger.reset() + if (!incrementalResultsMerger.hasNext) { + // Last deferred payload: reset the incrementalResultsMerger for potential subsequent responses + incrementalResultsMerger.reset() } apolloResponse } @@ -329,7 +331,7 @@ private constructor( is ConnectionReEstablished, is OperationComplete, is GeneralError -> error("Unexpected event $response") } }.filterNot { - deferredJsonMerger.isEmptyPayload + incrementalResultsMerger.isEmptyResponse }.onCompletion { messages.send(StopOperation(request)) } @@ -372,6 +374,7 @@ private constructor( private var idleTimeoutMillis: Long? = null private var protocolFactory: WsProtocol.Factory? = null private var reopenWhen: (suspend (Throwable, attempt: Long) -> Boolean)? = null + private var incrementalDeliveryProtocol: IncrementalDeliveryProtocol = IncrementalDeliveryProtocol.GraphQL17Alpha2 /** * Configure the server URL. @@ -438,6 +441,16 @@ private constructor( this.reopenWhen = reopenWhen } + /** + * The incremental delivery protocol to use when using `@defer` and/or `@stream`. + * + * Default: [IncrementalDeliveryProtocol.GraphQL17Alpha2] + */ + @ApolloExperimental + fun incrementalDeliveryProtocol(incrementalDeliveryProtocol: IncrementalDeliveryProtocol) = apply { + this.incrementalDeliveryProtocol = incrementalDeliveryProtocol + } + fun build(): WebSocketNetworkTransport { return WebSocketNetworkTransport( serverUrl = serverUrl ?: error("No serverUrl specified"), @@ -445,7 +458,8 @@ private constructor( webSocketEngine = webSocketEngine ?: DefaultWebSocketEngine(), idleTimeoutMillis = idleTimeoutMillis ?: 60_000, protocolFactory = protocolFactory ?: SubscriptionWsProtocol.Factory(), - reopenWhen = reopenWhen + reopenWhen = reopenWhen, + incrementalDeliveryProtocolImpl = incrementalDeliveryProtocol.impl, ) } } @@ -460,3 +474,7 @@ fun NetworkTransport.closeConnection(reason: Throwable) { (this as? WebSocketNetworkTransport ?: throw IllegalArgumentException("'$this' is not an instance of com.apollographql.apollo.ws.WebSocketNetworkTransport")).closeConnection(reason) } + +private fun Map.isDeferred(): Boolean { + return keys.contains("hasNext") +} diff --git a/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/DeferredJsonMergerTest.kt b/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/DeferredJsonMergerTest.kt deleted file mode 100644 index 40918461ae3..00000000000 --- a/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/DeferredJsonMergerTest.kt +++ /dev/null @@ -1,713 +0,0 @@ -package test.defer - -import com.apollographql.apollo.api.DeferredFragmentIdentifier -import com.apollographql.apollo.api.json.BufferedSourceJsonReader -import com.apollographql.apollo.api.json.readAny -import com.apollographql.apollo.internal.DeferredJsonMerger -import okio.Buffer -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -private fun String.buffer() = Buffer().writeUtf8(this) - -@Suppress("UNCHECKED_CAST") -private fun jsonToMap(json: String): Map = BufferedSourceJsonReader(json.buffer()).readAny() as Map - -class DeferredJsonMergerTest { - @Test - fun mergeJsonSingleIncrementalItem() { - val deferredJsonMerger = DeferredJsonMerger() - - val payload1 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "screen": { - "isTouch": true - } - }, - { - "id": "Computer2", - "screen": { - "isTouch": false - } - } - ] - }, - "hasNext": true - } - """ - deferredJsonMerger.merge(payload1.buffer()) - assertEquals(jsonToMap(payload1), deferredJsonMerger.merged) - assertEquals(setOf(), deferredJsonMerger.mergedFragmentIds) - - - val payload2 = """ - { - "incremental": [ - { - "data": { - "cpu": "386", - "year": 1993, - "screen": { - "resolution": "640x480" - } - }, - "path": [ - "computers", - 0 - ], - "label": "query:Query1:0", - "extensions": { - "duration": { - "amount": 100, - "unit": "ms" - } - } - } - ], - "hasNext": true - } - """ - val mergedPayloads_1_2 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "cpu": "386", - "year": 1993, - "screen": { - "isTouch": true, - "resolution": "640x480" - } - }, - { - "id": "Computer2", - "screen": { - "isTouch": false - } - } - ] - }, - "hasNext": true, - "extensions": { - "incremental": [ - { - "duration": { - "amount": 100, - "unit": "ms" - } - } - ] - } - } - """ - deferredJsonMerger.merge(payload2.buffer()) - assertEquals(jsonToMap(mergedPayloads_1_2), deferredJsonMerger.merged) - assertEquals(setOf( - DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), - ), deferredJsonMerger.mergedFragmentIds - ) - - - val payload3 = """ - { - "incremental": [ - { - "data": { - "cpu": "486", - "year": 1996, - "screen": { - "resolution": "640x480" - } - }, - "path": [ - "computers", - 1 - ], - "label": "query:Query1:0", - "extensions": { - "duration": { - "amount": 25, - "unit": "ms" - } - } - } - ], - "hasNext": true - } - """ - val mergedPayloads_1_2_3 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "cpu": "386", - "year": 1993, - "screen": { - "isTouch": true, - "resolution": "640x480" - } - }, - { - "id": "Computer2", - "cpu": "486", - "year": 1996, - "screen": { - "isTouch": false, - "resolution": "640x480" - } - } - ] - }, - "hasNext": true, - "extensions": { - "incremental": [ - { - "duration": { - "amount": 25, - "unit": "ms" - } - } - ] - } - } - """ - deferredJsonMerger.merge(payload3.buffer()) - assertEquals(jsonToMap(mergedPayloads_1_2_3), deferredJsonMerger.merged) - assertEquals(setOf( - DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), - DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), - ), deferredJsonMerger.mergedFragmentIds - ) - - - val payload4 = """ - { - "incremental": [ - { - "data": null, - "path": [ - "computers", - 0, - "screen" - ], - "errors": [ - { - "message": "Cannot resolve isColor", - "locations": [ - { - "line": 12, - "column": 11 - } - ], - "path": [ - "computers", - 0, - "screen", - "isColor" - ] - } - ], - "label": "fragment:ComputerFields:0" - } - ], - "hasNext": true - } - """ - val mergedPayloads_1_2_3_4 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "cpu": "386", - "year": 1993, - "screen": { - "isTouch": true, - "resolution": "640x480" - } - }, - { - "id": "Computer2", - "cpu": "486", - "year": 1996, - "screen": { - "isTouch": false, - "resolution": "640x480" - } - } - ] - }, - "hasNext": true, - "errors": [ - { - "message": "Cannot resolve isColor", - "locations": [ - { - "line": 12, - "column": 11 - } - ], - "path": [ - "computers", - 0, - "screen", - "isColor" - ] - } - ] - } - """ - deferredJsonMerger.merge(payload4.buffer()) - assertEquals(jsonToMap(mergedPayloads_1_2_3_4), deferredJsonMerger.merged) - assertEquals(setOf( - DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), - DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), - ), deferredJsonMerger.mergedFragmentIds - ) - - - val payload5 = """ - { - "incremental": [ - { - "data": { - "isColor": false - }, - "path": [ - "computers", - 1, - "screen" - ], - "errors": [ - { - "message": "Another error", - "locations": [ - { - "line": 1, - "column": 1 - } - ] - } - ], - "label": "fragment:ComputerFields:0", - "extensions": { - "value": 42, - "duration": { - "amount": 130, - "unit": "ms" - } - } - } - ], - "hasNext": false - } - """ - val mergedPayloads_1_2_3_4_5 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "cpu": "386", - "year": 1993, - "screen": { - "isTouch": true, - "resolution": "640x480" - } - }, - { - "id": "Computer2", - "cpu": "486", - "year": 1996, - "screen": { - "isTouch": false, - "resolution": "640x480", - "isColor": false - } - } - ] - }, - "hasNext": true, - "extensions": { - "incremental": [ - { - "value": 42, - "duration": { - "amount": 130, - "unit": "ms" - } - } - ] - }, - "errors": [ - { - "message": "Another error", - "locations": [ - { - "line": 1, - "column": 1 - } - ] - } - ] - } - """ - deferredJsonMerger.merge(payload5.buffer()) - assertEquals(jsonToMap(mergedPayloads_1_2_3_4_5), deferredJsonMerger.merged) - assertEquals(setOf( - DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), - DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), - DeferredFragmentIdentifier(path = listOf("computers", 1, "screen"), label = "fragment:ComputerFields:0"), - ), deferredJsonMerger.mergedFragmentIds - ) - } - - @Test - fun mergeJsonMultipleIncrementalItems() { - val deferredJsonMerger = DeferredJsonMerger() - - val payload1 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "screen": { - "isTouch": true - } - }, - { - "id": "Computer2", - "screen": { - "isTouch": false - } - } - ] - }, - "hasNext": true - } - """ - deferredJsonMerger.merge(payload1.buffer()) - assertEquals(jsonToMap(payload1), deferredJsonMerger.merged) - assertEquals(setOf(), deferredJsonMerger.mergedFragmentIds) - - - val payload2_3 = """ - { - "incremental": [ - { - "data": { - "cpu": "386", - "year": 1993, - "screen": { - "resolution": "640x480" - } - }, - "path": [ - "computers", - 0 - ], - "label": "query:Query1:0", - "extensions": { - "duration": { - "amount": 100, - "unit": "ms" - } - } - }, - { - "data": { - "cpu": "486", - "year": 1996, - "screen": { - "resolution": "640x480" - } - }, - "path": [ - "computers", - 1 - ], - "label": "query:Query1:0", - "extensions": { - "duration": { - "amount": 25, - "unit": "ms" - } - } - } - ], - "hasNext": true - } - """ - val mergedPayloads_1_2_3 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "cpu": "386", - "year": 1993, - "screen": { - "isTouch": true, - "resolution": "640x480" - } - }, - { - "id": "Computer2", - "cpu": "486", - "year": 1996, - "screen": { - "isTouch": false, - "resolution": "640x480" - } - } - ] - }, - "hasNext": true, - "extensions": { - "incremental": [ - { - "duration": { - "amount": 100, - "unit": "ms" - } - }, - { - "duration": { - "amount": 25, - "unit": "ms" - } - } - ] - } - } - """ - deferredJsonMerger.merge(payload2_3.buffer()) - assertEquals(jsonToMap(mergedPayloads_1_2_3), deferredJsonMerger.merged) - assertEquals(setOf( - DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), - DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), - ), deferredJsonMerger.mergedFragmentIds - ) - - - val payload4_5 = """ - { - "incremental": [ - { - "data": null, - "path": [ - "computers", - 0, - "screen" - ], - "errors": [ - { - "message": "Cannot resolve isColor", - "locations": [ - { - "line": 12, - "column": 11 - } - ], - "path": [ - "computers", - 0, - "screen", - "isColor" - ] - } - ], - "label": "fragment:ComputerFields:0" - }, - { - "data": { - "isColor": false - }, - "path": [ - "computers", - 1, - "screen" - ], - "errors": [ - { - "message": "Another error", - "locations": [ - { - "line": 1, - "column": 1 - } - ] - } - ], - "label": "fragment:ComputerFields:0", - "extensions": { - "value": 42, - "duration": { - "amount": 130, - "unit": "ms" - } - } - } - ], - "hasNext": true - } - """ - val mergedPayloads_1_2_3_4_5 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "cpu": "386", - "year": 1993, - "screen": { - "isTouch": true, - "resolution": "640x480" - } - }, - { - "id": "Computer2", - "cpu": "486", - "year": 1996, - "screen": { - "isTouch": false, - "resolution": "640x480", - "isColor": false - } - } - ] - }, - "hasNext": true, - "extensions": { - "incremental": [ - { - "value": 42, - "duration": { - "amount": 130, - "unit": "ms" - } - } - ] - }, - "errors": [ - { - "message": "Cannot resolve isColor", - "locations": [ - { - "line": 12, - "column": 11 - } - ], - "path": [ - "computers", - 0, - "screen", - "isColor" - ] - }, - { - "message": "Another error", - "locations": [ - { - "line": 1, - "column": 1 - } - ] - } - ] - } - """ - deferredJsonMerger.merge(payload4_5.buffer()) - assertEquals(jsonToMap(mergedPayloads_1_2_3_4_5), deferredJsonMerger.merged) - assertEquals(setOf( - DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), - DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), - DeferredFragmentIdentifier(path = listOf("computers", 1, "screen"), label = "fragment:ComputerFields:0"), - ), deferredJsonMerger.mergedFragmentIds - ) - } - - @Test - fun emptyPayloads() { - val deferredJsonMerger = DeferredJsonMerger() - - val payload1 = """ - { - "data": { - "computers": [ - { - "id": "Computer1", - "screen": { - "isTouch": true - } - }, - { - "id": "Computer2", - "screen": { - "isTouch": false - } - } - ] - }, - "hasNext": true - } - """ - deferredJsonMerger.merge(payload1.buffer()) - assertFalse(deferredJsonMerger.isEmptyPayload) - - val payload2 = """ - { - "hasNext": true - } - """ - deferredJsonMerger.merge(payload2.buffer()) - assertTrue(deferredJsonMerger.isEmptyPayload) - - val payload3 = """ - { - "incremental": [ - { - "data": { - "cpu": "386", - "year": 1993, - "screen": { - "resolution": "640x480" - } - }, - "path": [ - "computers", - 0 - ], - "label": "query:Query1:0", - "extensions": { - "duration": { - "amount": 100, - "unit": "ms" - } - } - } - ], - "hasNext": true - } - """ - deferredJsonMerger.merge(payload3.buffer()) - assertFalse(deferredJsonMerger.isEmptyPayload) - - val payload4 = """ - { - "hasNext": false - } - """ - deferredJsonMerger.merge(payload4.buffer()) - assertTrue(deferredJsonMerger.isEmptyPayload) - } -} \ No newline at end of file diff --git a/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/GraphQL17Alpha2IncrementalResultsMergerTest.kt b/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/GraphQL17Alpha2IncrementalResultsMergerTest.kt new file mode 100644 index 00000000000..b5226574b78 --- /dev/null +++ b/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/GraphQL17Alpha2IncrementalResultsMergerTest.kt @@ -0,0 +1,747 @@ +@file:OptIn(ApolloInternal::class) + +package test.defer + +import com.apollographql.apollo.annotations.ApolloInternal +import com.apollographql.apollo.api.DeferredFragmentIdentifier +import com.apollographql.apollo.api.json.BufferedSourceJsonReader +import com.apollographql.apollo.api.json.readAny +import com.apollographql.apollo.internal.incremental.GraphQL17Alpha2IncrementalResultsMerger +import okio.Buffer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private fun String.buffer() = Buffer().writeUtf8(this) + +@Suppress("UNCHECKED_CAST") +private fun jsonToMap(json: String): Map = BufferedSourceJsonReader(json.buffer()).readAny() as Map + +class GraphQL17Alpha2IncrementalResultsMergerTest { + @Test + fun mergeJsonSingleIncrementalItem() { + val incrementalResultsMerger = GraphQL17Alpha2IncrementalResultsMerger() + + //language=JSON + val payload1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(payload1), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "data": { + "cpu": "386", + "year": 1993, + "screen": { + "resolution": "640x480" + } + }, + "path": [ + "computers", + 0 + ], + "label": "query:Query1:0", + "extensions": { + "duration": { + "amount": 100, + "unit": "ms" + } + } + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "hasNext": true, + "extensions": { + "incremental": [ + { + "duration": { + "amount": 100, + "unit": "ms" + } + } + ] + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "data": { + "cpu": "486", + "year": 1996, + "screen": { + "resolution": "640x480" + } + }, + "path": [ + "computers", + 1 + ], + "label": "query:Query1:0", + "extensions": { + "duration": { + "amount": 25, + "unit": "ms" + } + } + } + ], + "hasNext": true + } + """.trimIndent() + + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480" + } + } + ] + }, + "hasNext": true, + "extensions": { + "incremental": [ + { + "duration": { + "amount": 25, + "unit": "ms" + } + } + ] + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + + //language=JSON + val payload4 = """ + { + "incremental": [ + { + "data": null, + "path": [ + "computers", + 0, + "screen" + ], + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + } + ], + "label": "fragment:ComputerFields:0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3_4 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480" + } + } + ] + }, + "hasNext": true, + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + } + ] + } + """.trimIndent() + incrementalResultsMerger.merge(payload4.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3_4), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + + //language=JSON + val payload5 = """ + { + "incremental": [ + { + "data": { + "isColor": false + }, + "path": [ + "computers", + 1, + "screen" + ], + "errors": [ + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ], + "label": "fragment:ComputerFields:0", + "extensions": { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + } + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3_4_5 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480", + "isColor": false + } + } + ] + }, + "hasNext": true, + "extensions": { + "incremental": [ + { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + } + ] + }, + "errors": [ + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ] + } + """.trimIndent() + incrementalResultsMerger.merge(payload5.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3_4_5), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1, "screen"), label = "fragment:ComputerFields:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + } + + @Test + fun mergeJsonMultipleIncrementalItems() { + val incrementalResultsMerger = GraphQL17Alpha2IncrementalResultsMerger() + + //language=JSON + val payload1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(payload1), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + + //language=JSON + val payload2_3 = """ + { + "incremental": [ + { + "data": { + "cpu": "386", + "year": 1993, + "screen": { + "resolution": "640x480" + } + }, + "path": [ + "computers", + 0 + ], + "label": "query:Query1:0", + "extensions": { + "duration": { + "amount": 100, + "unit": "ms" + } + } + }, + { + "data": { + "cpu": "486", + "year": 1996, + "screen": { + "resolution": "640x480" + } + }, + "path": [ + "computers", + 1 + ], + "label": "query:Query1:0", + "extensions": { + "duration": { + "amount": 25, + "unit": "ms" + } + } + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480" + } + } + ] + }, + "hasNext": true, + "extensions": { + "incremental": [ + { + "duration": { + "amount": 100, + "unit": "ms" + } + }, + { + "duration": { + "amount": 25, + "unit": "ms" + } + } + ] + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2_3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + + //language=JSON + val payload4_5 = """ + { + "incremental": [ + { + "data": null, + "path": [ + "computers", + 0, + "screen" + ], + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + } + ], + "label": "fragment:ComputerFields:0" + }, + { + "data": { + "isColor": false + }, + "path": [ + "computers", + 1, + "screen" + ], + "errors": [ + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ], + "label": "fragment:ComputerFields:0", + "extensions": { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + } + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3_4_5 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480", + "isColor": false + } + } + ] + }, + "hasNext": true, + "extensions": { + "incremental": [ + { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + } + ] + }, + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + }, + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ] + } + """.trimIndent() + incrementalResultsMerger.merge(payload4_5.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3_4_5), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1, "screen"), label = "fragment:ComputerFields:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers + ) + } + + @Test + fun emptyPayloads() { + val incrementalResultsMerger = GraphQL17Alpha2IncrementalResultsMerger() + + //language=JSON + val payload1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertFalse(incrementalResultsMerger.isEmptyResponse) + + //language=JSON + val payload2 = """ + { + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertTrue(incrementalResultsMerger.isEmptyResponse) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "data": { + "cpu": "386", + "year": 1993, + "screen": { + "resolution": "640x480" + } + }, + "path": [ + "computers", + 0 + ], + "label": "query:Query1:0", + "extensions": { + "duration": { + "amount": 100, + "unit": "ms" + } + } + } + ], + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertFalse(incrementalResultsMerger.isEmptyResponse) + + //language=JSON + val payload4 = """ + { + "hasNext": false + } + """.trimIndent() + incrementalResultsMerger.merge(payload4.buffer()) + assertTrue(incrementalResultsMerger.isEmptyResponse) + } +} diff --git a/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/GraphQL17Alpha9IncrementalResultsMergerTest.kt b/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/GraphQL17Alpha9IncrementalResultsMergerTest.kt new file mode 100644 index 00000000000..796f3d039e0 --- /dev/null +++ b/libraries/apollo-runtime/src/commonTest/kotlin/test/defer/GraphQL17Alpha9IncrementalResultsMergerTest.kt @@ -0,0 +1,2514 @@ +@file:OptIn(ApolloInternal::class) + +package test.defer + +import com.apollographql.apollo.annotations.ApolloInternal +import com.apollographql.apollo.api.DeferredFragmentIdentifier +import com.apollographql.apollo.api.json.BufferedSourceJsonReader +import com.apollographql.apollo.api.json.readAny +import com.apollographql.apollo.internal.incremental.GraphQL17Alpha9IncrementalResultsMerger +import okio.Buffer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private fun String.buffer() = Buffer().writeUtf8(this) + +@Suppress("UNCHECKED_CAST") +private fun jsonToMap(json: String): Map = BufferedSourceJsonReader(json.buffer()).readAny() as Map + +class GraphQL17Alpha9IncrementalResultsMergerTest { + @Test + fun mergeJsonSingleIncrementalItem() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + + //language=JSON + val payload1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "pending": [ + { + "id": "0", + "path": [ + "computers", + 0 + ], + "label": "query:Query1:0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0") + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "data": { + "cpu": "386", + "year": 1993, + "screen": { + "resolution": "640x480" + } + }, + "id": "0" + } + ], + "completed": [ + { + "id": "0" + } + ], + "pending": [ + { + "id": "1", + "path": [ + "computers", + 1 + ], + "label": "query:Query1:0" + } + ], + "extensions": { + "duration": { + "amount": 100, + "unit": "ms" + } + }, + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "extensions": { + "duration": { + "amount": 100, + "unit": "ms" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0") + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "data": { + "cpu": "486", + "year": 1996, + "screen": { + "resolution": "640x480" + } + }, + "id": "1" + } + ], + "completed": [ + { + "id": "1" + } + ], + "pending": [ + { + "id": "2", + "path": [ + "computers", + 0, + "screen" + ], + "label": "fragment:ComputerFields:0" + } + ], + "extensions": { + "duration": { + "amount": 25, + "unit": "ms" + } + }, + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480" + } + } + ] + }, + "extensions": { + "duration": { + "amount": 25, + "unit": "ms" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0, "screen"), label = "fragment:ComputerFields:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload4 = """ + { + "completed": [ + { + "id": "2", + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + } + ] + } + ], + "pending": [ + { + "id": "3", + "path": [ + "computers", + 1, + "screen" + ], + "label": "fragment:ComputerFields:0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3_4 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480" + } + } + ] + }, + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + } + ], + "extensions": { + "duration": { + "amount": 25, + "unit": "ms" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload4.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3_4), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0, "screen"), label = "fragment:ComputerFields:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1, "screen"), label = "fragment:ComputerFields:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload5 = """ + { + "incremental": [ + { + "data": { + "isColor": false + }, + "id": "3", + "errors": [ + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ] + } + ], + "completed": [ + { + "id": "3" + } + ], + "extensions": { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + }, + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3_4_5 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480", + "isColor": false + } + } + ] + }, + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + }, + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ], + "extensions": { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload5.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3_4_5), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0, "screen"), label = "fragment:ComputerFields:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + @Test + fun mergeJsonMultipleIncrementalItems() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + + //language=JSON + val payload1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "pending": [ + { + "id": "0", + "path": [ + "computers", + 0 + ], + "label": "query:Query1:0" + }, + { + "id": "1", + "path": [ + "computers", + 1 + ], + "label": "query:Query1:0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0), label = "query:Query1:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1), label = "query:Query1:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2_3 = """ + { + "incremental": [ + { + "data": { + "cpu": "386", + "year": 1993, + "screen": { + "resolution": "640x480" + } + }, + "id": "0" + }, + { + "data": { + "cpu": "486", + "year": 1996, + "screen": { + "resolution": "640x480" + } + }, + "id": "1" + } + ], + "completed": [ + { + "id": "0" + }, + { + "id": "1" + } + ], + "pending": [ + { + "id": "2", + "path": [ + "computers", + 0, + "screen" + ], + "label": "fragment:ComputerFields:0" + }, + { + "id": "3", + "path": [ + "computers", + 1, + "screen" + ], + "label": "fragment:ComputerFields:0" + } + ], + "extensions": { + "duration": { + "amount": 100, + "unit": "ms" + } + }, + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480" + } + } + ] + }, + "extensions": { + "duration": { + "amount": 100, + "unit": "ms" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2_3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0, "screen"), label = "fragment:ComputerFields:0"), + DeferredFragmentIdentifier(path = listOf("computers", 1, "screen"), label = "fragment:ComputerFields:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload4_5 = """ + { + "incremental": [ + { + "data": { + "isColor": false + }, + "id": "3", + "errors": [ + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ] + } + ], + "completed": [ + { + "id": "2", + "errors": [ + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + } + ] + }, + { + "id": "3" + } + ], + "extensions": { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + }, + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3_4_5 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "cpu": "386", + "year": 1993, + "screen": { + "isTouch": true, + "resolution": "640x480" + } + }, + { + "id": "Computer2", + "cpu": "486", + "year": 1996, + "screen": { + "isTouch": false, + "resolution": "640x480", + "isColor": false + } + } + ] + }, + "errors": [ + { + "message": "Another error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + }, + { + "message": "Cannot resolve isColor", + "locations": [ + { + "line": 12, + "column": 11 + } + ], + "path": [ + "computers", + 0, + "screen", + "isColor" + ] + } + ], + "extensions": { + "value": 42, + "duration": { + "amount": 130, + "unit": "ms" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload4_5.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3_4_5), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("computers", 0, "screen"), label = "fragment:ComputerFields:0"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + @Test + fun emptyPayloads() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + + //language=JSON + val payload1 = """ + { + "data": { + "computers": [ + { + "id": "Computer1", + "screen": { + "isTouch": true + } + }, + { + "id": "Computer2", + "screen": { + "isTouch": false + } + } + ] + }, + "pending": [ + { + "id": "0", + "path": [ + "computers", + 0 + ], + "label": "query:Query1:0" + }, + { + "id": "1", + "path": [ + "computers", + 1 + ], + "label": "query:Query1:0" + } + ], + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertFalse(incrementalResultsMerger.isEmptyResponse) + + //language=JSON + val payload2 = """ + { + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertTrue(incrementalResultsMerger.isEmptyResponse) + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "data": { + "cpu": "386", + "year": 1993, + "screen": { + "resolution": "640x480" + } + }, + "id": "0" + } + ], + "hasNext": true + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertFalse(incrementalResultsMerger.isEmptyResponse) + + //language=JSON + val payload4 = """ + { + "hasNext": false + } + """.trimIndent() + incrementalResultsMerger.merge(payload4.buffer()) + assertTrue(incrementalResultsMerger.isEmptyResponse) + } + + /** + * Example A from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleA() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "f2": { + "a": "a", + "b": "b", + "c": { + "d": "d", + "e": "e", + "f": { + "h": "h", + "i": "i" + } + } + } + }, + "pending": [ + { + "path": [], + "id": "0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "f2": { + "a": "a", + "b": "b", + "c": { + "d": "d", + "e": "e", + "f": { + "h": "h", + "i": "i" + } + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = null), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "0", + "data": { + "MyFragment": "Query" + } + }, + { + "id": "0", + "subPath": [ + "f2", + "c", + "f" + ], + "data": { + "j": "j" + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "f2": { + "a": "a", + "b": "b", + "c": { + "d": "d", + "e": "e", + "f": { + "h": "h", + "i": "i", + "j": "j" + } + } + }, + "MyFragment": "Query" + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example A2 from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleA2() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "f2": { + "a": "A", + "b": "B", + "c": { + "d": "D", + "e": "E", + "f": { + "h": "H", + "i": "I" + } + } + } + }, + "pending": [ + { + "id": "0", + "path": [], + "label": "D1" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "f2": { + "a": "A", + "b": "B", + "c": { + "d": "D", + "e": "E", + "f": { + "h": "H", + "i": "I" + } + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = "D1"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "0", + "subPath": [ + "f2", + "c", + "f" + ], + "data": { + "j": "J", + "k": "K" + } + } + ], + "pending": [ + { + "id": "1", + "path": [ + "f2", + "c", + "f" + ], + "label": "D2" + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "f2": { + "a": "A", + "b": "B", + "c": { + "d": "D", + "e": "E", + "f": { + "h": "H", + "i": "I", + "j": "J", + "k": "K" + } + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("f2", "c", "f"), label = "D2"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "id": "1", + "data": { + "l": "L", + "m": "M" + } + } + ], + "completed": [ + { + "id": "1" + } + ], + "hasNext": false + } + """.trimIndent() + + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "f2": { + "a": "A", + "b": "B", + "c": { + "d": "D", + "e": "E", + "f": { + "h": "H", + "i": "I", + "j": "J", + "k": "K", + "l": "L", + "m": "M" + } + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example B1 from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleB1() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + } + } + } + }, + "pending": [ + { + "path": [], + "id": "0", + "label": "Blue" + }, + { + "path": [ + "a", + "b" + ], + "id": "1", + "label": "Red" + } + ], + "hasNext": true + } + """.trimIndent() + + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + } + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = "Blue"), + DeferredFragmentIdentifier(path = listOf("a", "b"), label = "Red"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "1", + "data": { + "potentiallySlowFieldA": "potentiallySlowFieldA" + } + }, + { + "id": "1", + "data": { + "e": { + "f": "f" + } + } + } + ], + "completed": [ + { + "id": "1" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + }, + "e": { + "f": "f" + }, + "potentiallySlowFieldA": "potentiallySlowFieldA" + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = "Blue"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "id": "0", + "data": { + "g": { + "h": "h" + }, + "potentiallySlowFieldB": "potentiallySlowFieldB" + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + }, + "e": { + "f": "f" + }, + "potentiallySlowFieldA": "potentiallySlowFieldA" + } + }, + "g": { + "h": "h" + }, + "potentiallySlowFieldB": "potentiallySlowFieldB" + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example B2 from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleB2() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + } + } + } + }, + "pending": [ + { + "path": [], + "id": "0", + "label": "Blue" + }, + { + "path": [ + "a", + "b" + ], + "id": "1", + "label": "Red" + } + ], + "hasNext": true + } + """.trimIndent() + + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + } + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = "Blue"), + DeferredFragmentIdentifier(path = listOf("a", "b"), label = "Red"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "0", + "data": { + "g": { + "h": "h" + }, + "potentiallySlowFieldB": "potentiallySlowFieldB" + } + }, + { + "id": "1", + "data": { + "e": { + "f": "f" + } + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + }, + "e": { + "f": "f" + } + } + }, + "g": { + "h": "h" + }, + "potentiallySlowFieldB": "potentiallySlowFieldB" + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("a", "b"), label = "Red"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "id": "1", + "data": { + "potentiallySlowFieldA": "potentiallySlowFieldA" + } + } + ], + "completed": [ + { + "id": "1" + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "a": { + "b": { + "c": { + "d": "d" + }, + "e": { + "f": "f" + }, + "potentiallySlowFieldA": "potentiallySlowFieldA" + } + }, + "g": { + "h": "h" + }, + "potentiallySlowFieldB": "potentiallySlowFieldB" + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example D from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleD() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "me": {} + }, + "pending": [ + { + "path": [], + "id": "0" + }, + { + "path": [ + "me" + ], + "id": "1" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "me": {} + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = null), + DeferredFragmentIdentifier(path = listOf("me"), label = null), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "1", + "data": { + "list": [ + { + "item": {} + }, + { + "item": {} + }, + { + "item": {} + } + ] + } + }, + { + "id": "1", + "subPath": [ + "list", + 0, + "item" + ], + "data": { + "id": "1" + } + }, + { + "id": "1", + "subPath": [ + "list", + 1, + "item" + ], + "data": { + "id": "2" + } + }, + { + "id": "1", + "subPath": [ + "list", + 2, + "item" + ], + "data": { + "id": "3" + } + } + ], + "completed": [ + { + "id": "1" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "me": { + "list": [ + { + "item": { + "id": "1" + } + }, + { + "item": { + "id": "2" + } + }, + { + "item": { + "id": "3" + } + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = null), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "id": "0", + "subPath": [ + "me", + "list", + 0, + "item" + ], + "data": { + "value": "Foo" + } + }, + { + "id": "0", + "subPath": [ + "me", + "list", + 1, + "item" + ], + "data": { + "value": "Bar" + } + }, + { + "id": "0", + "subPath": [ + "me", + "list", + 2, + "item" + ], + "data": { + "value": "Baz" + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "me": { + "list": [ + { + "item": { + "id": "1", + "value": "Foo" + } + }, + { + "item": { + "id": "2", + "value": "Bar" + } + }, + { + "item": { + "id": "3", + "value": "Baz" + } + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example F from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleF() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "me": {} + }, + "pending": [ + { + "id": "0", + "path": [ + "me" + ], + "label": "B" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "me": {} + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("me"), label = "B"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "0", + "data": { + "a": "A", + "b": "B" + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "me": { + "a": "A", + "b": "B" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example G from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleG() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "me": { + "id": 1, + "avatarUrl": "http://…", + "projects": [ + { + "name": "My Project" + } + ] + } + }, + "pending": [ + { + "id": "0", + "path": [ + "me" + ], + "label": "Billing" + }, + { + "id": "1", + "path": [ + "me" + ], + "label": "Prev" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "me": { + "id": 1, + "avatarUrl": "http://…", + "projects": [ + { + "name": "My Project" + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("me"), label = "Billing"), + DeferredFragmentIdentifier(path = listOf("me"), label = "Prev"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "0", + "data": { + "tier": "BRONZE", + "renewalDate": "2023-03-20", + "latestInvoiceTotal": "${'$'}12.34" + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "me": { + "id": 1, + "avatarUrl": "http://…", + "projects": [ + { + "name": "My Project" + } + ], + "tier": "BRONZE", + "renewalDate": "2023-03-20", + "latestInvoiceTotal": "${'$'}12.34" + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("me"), label = "Prev"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "incremental": [ + { + "id": "1", + "data": { + "previousInvoices": [ + { + "name": "My Invoice" + } + ] + } + } + ], + "completed": [ + { + "id": "1" + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "me": { + "id": 1, + "avatarUrl": "http://…", + "projects": [ + { + "name": "My Project" + } + ], + "tier": "BRONZE", + "renewalDate": "2023-03-20", + "latestInvoiceTotal": "${'$'}12.34", + "previousInvoices": [ + { + "name": "My Invoice" + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example H from https://github.com/graphql/defer-stream-wg/discussions/69 (Dec 13 2024 version) + */ + @Test + fun june2023ExampleH() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "me": {} + }, + "pending": [ + { + "id": "0", + "path": [], + "label": "A" + }, + { + "id": "1", + "path": [ + "me" + ], + "label": "B" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "me": {} + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf(), label = "A"), + DeferredFragmentIdentifier(path = listOf("me"), label = "B"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "0", + "subPath": [ + "me" + ], + "data": { + "foo": { + "bar": {} + } + } + }, + { + "id": "0", + "subPath": [ + "me", + "foo", + "bar" + ], + "data": { + "baz": "BAZ" + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "me": { + "foo": { + "bar": { + "baz": "BAZ" + } + } + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("me"), label = "B"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "completed": [ + { + "id": "1", + "errors": [ + { + "message": "Cannot return null for non-nullable field Bar.qux.", + "locations": [ + { + "line": 1, + "column": 1 + } + ], + "path": [ + "foo", + "bar", + "qux" + ] + } + ] + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "me": { + "foo": { + "bar": { + "baz": "BAZ" + } + } + } + }, + "errors": [ + { + "message": "Cannot return null for non-nullable field Bar.qux.", + "locations": [ + { + "line": 1, + "column": 1 + } + ], + "path": [ + "foo", + "bar", + "qux" + ] + } + ] + } + """.trimIndent() + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("me"), label = "B"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example I from https://github.com/graphql/defer-stream-wg/discussions/69 (Jul 18 2025 version) + */ + @Test + fun july2025ExampleI() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "person": { + "name": "Luke Skywalker", + "films": [ + { + "title": "A New Hope" + }, + { + "title": "The Empire Strikes Back" + } + ] + } + }, + "pending": [ + { + "id": "0", + "path": [ + "person" + ], + "label": "homeWorldDefer" + }, + { + "id": "1", + "path": [ + "person", + "films" + ], + "label": "filmsStream" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "person": { + "name": "Luke Skywalker", + "films": [ + { + "title": "A New Hope" + }, + { + "title": "The Empire Strikes Back" + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("person"), label = "homeWorldDefer"), + DeferredFragmentIdentifier(path = listOf("person", "films"), label = "filmsStream"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "1", + "items": [ + { + "title": "Return of the Jedi" + } + ] + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "person": { + "name": "Luke Skywalker", + "films": [ + { + "title": "A New Hope" + }, + { + "title": "The Empire Strikes Back" + }, + { + "title": "Return of the Jedi" + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("person"), label = "homeWorldDefer"), + DeferredFragmentIdentifier(path = listOf("person", "films"), label = "filmsStream"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "completed": [ + { + "id": "1" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "person": { + "name": "Luke Skywalker", + "films": [ + { + "title": "A New Hope" + }, + { + "title": "The Empire Strikes Back" + }, + { + "title": "Return of the Jedi" + } + ] + } + } + } + """.trimIndent() + + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("person"), label = "homeWorldDefer"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload4 = """ + { + "incremental": [ + { + "id": "0", + "data": { + "homeworld": { + "name": "Tatooine" + } + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3_4 = """ + { + "data": { + "person": { + "name": "Luke Skywalker", + "homeworld": { + "name": "Tatooine" + }, + "films": [ + { + "title": "A New Hope" + }, + { + "title": "The Empire Strikes Back" + }, + { + "title": "Return of the Jedi" + } + ] + } + } + } + """.trimIndent() + + incrementalResultsMerger.merge(payload4.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3_4), incrementalResultsMerger.merged) + assertEquals( + setOf(), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } + + /** + * Example J from https://github.com/graphql/defer-stream-wg/discussions/69 (Jul 18 2025 version) + */ + @Test + fun july2025ExampleJ() { + val incrementalResultsMerger = GraphQL17Alpha9IncrementalResultsMerger() + //language=JSON + val payload1 = """ + { + "data": { + "person": { + "films": [ + { + "title": "A New Hope" + } + ] + } + }, + "pending": [ + { + "id": "1", + "path": [ + "person", + "films" + ], + "label": "filmsStream" + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1 = """ + { + "data": { + "person": { + "films": [ + { + "title": "A New Hope" + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload1.buffer()) + assertEquals(jsonToMap(mergedPayloads_1), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("person", "films"), label = "filmsStream"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload2 = """ + { + "incremental": [ + { + "id": "1", + "items": [ + { + "title": "The Empire Strikes Back" + } + ] + } + ], + "hasNext": true + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2 = """ + { + "data": { + "person": { + "films": [ + { + "title": "A New Hope" + }, + { + "title": "The Empire Strikes Back" + } + ] + } + } + } + """.trimIndent() + incrementalResultsMerger.merge(payload2.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("person", "films"), label = "filmsStream"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + + //language=JSON + val payload3 = """ + { + "completed": [ + { + "id": "1", + "errors": [ + { + "message": "Cannot return null for non-nullable field Person.films.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "person", + "films" + ] + } + ] + } + ], + "hasNext": false + } + """.trimIndent() + //language=JSON + val mergedPayloads_1_2_3 = """ + { + "data": { + "person": { + "films": [ + { + "title": "A New Hope" + }, + { + "title": "The Empire Strikes Back" + } + ] + } + }, + "errors": [ + { + "message": "Cannot return null for non-nullable field Person.films.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "person", + "films" + ] + } + ] + } + """.trimIndent() + + incrementalResultsMerger.merge(payload3.buffer()) + assertEquals(jsonToMap(mergedPayloads_1_2_3), incrementalResultsMerger.merged) + assertEquals( + setOf( + DeferredFragmentIdentifier(path = listOf("person", "films"), label = "filmsStream"), + ), + incrementalResultsMerger.deferredFragmentIdentifiers.nonPending() + ) + } +} + +private fun Set.nonPending(): Set { + return filter { it !== DeferredFragmentIdentifier.Pending }.toSet() +} diff --git a/tests/defer/README.md b/tests/defer/README.md index 9e4ad207f3d..4f4ac085620 100644 --- a/tests/defer/README.md +++ b/tests/defer/README.md @@ -16,3 +16,16 @@ To run them locally: subgraph: `(cd tests/defer/router/subgraphs/computers && npm install && APOLLO_PORT=4001 npm start)&` 2. Run the router: `path/to/router --supergraph tests/defer/router/simple-supergraph.graphqls &` 3. Run the tests: `DEFER_WITH_ROUTER_TESTS=true ./gradlew -p tests :defer:allTests` + +## End-to-end tests with Apollo Server + +The tests in `DeferWithApolloServerTest` are not run by default (they are excluded in the gradle conf) because they +expect an instance of [Apollo Server](https://www.apollographql.com/docs/apollo-server) running locally. + +They are enabled only when running from the specific `defer-with-apollo-server-tests` CI workflow. + +To run them locally: + +1. Install and run the + subgraph: `(cd tests/defer/apollo-server && npm install --legacy-peer-deps && npx patch-package && APOLLO_PORT=4000 npm start)&` +2. Run the tests: `DEFER_WITH_APOLLO_SERVER_TESTS=true ./gradlew -p tests :defer:allTests` diff --git a/tests/defer/apollo-server/README.md b/tests/defer/apollo-server/README.md new file mode 100644 index 00000000000..5111b87d42e --- /dev/null +++ b/tests/defer/apollo-server/README.md @@ -0,0 +1,4 @@ +# Test server using Apollo Server, for `@defer` tests + +- This uses graphql-js `17.0.0-alpha.9`, which implements the latest draft of the `@defer` incremental format (as of 2025-09-24). +- Apollo Server `4.11.2` needs a patch (in `patches`) to surface this format in the responses. diff --git a/tests/defer/apollo-server/computers.graphqls b/tests/defer/apollo-server/computers.graphqls new file mode 100644 index 00000000000..a1875342b39 --- /dev/null +++ b/tests/defer/apollo-server/computers.graphqls @@ -0,0 +1,34 @@ +type Query { + computers: [Computer!]! + computer(id: ID!): Computer +} + +type Mutation { + computers: [Computer!]! +} + +type Computer { + id: ID! + cpu: String! + year: Int! + screen: Screen! + errorField: String + nonNullErrorField: String! + peripherals: [String!]! +} + +type Screen { + resolution: String! + isColor: Boolean! +} + +directive @defer( + if: Boolean! = true + label: String +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +directive @stream( + label: String + if: Boolean! = true + initialCount: Int = 0 +) on FIELD diff --git a/tests/defer/apollo-server/computers.js b/tests/defer/apollo-server/computers.js new file mode 100644 index 00000000000..77e673b7985 --- /dev/null +++ b/tests/defer/apollo-server/computers.js @@ -0,0 +1,52 @@ +import {ApolloServer} from '@apollo/server'; +import {startStandaloneServer} from '@apollo/server/standalone'; +import {readFileSync} from 'fs'; + +const port = process.env.APOLLO_PORT || 4000; + +const computers = [ + { + id: 'Computer1', + cpu: "386", + year: 1993, + screen: {resolution: "640x480", isColor: false}, + peripherals: ["Keyboard", "Mouse", "Printer"], + }, + { + id: 'Computer2', + cpu: "486", + year: 1996, + screen: {resolution: "800x600", isColor: true}, + peripherals: ["Keyboard", "Mouse", "Printer", "Scanner"], + }, +] + +const typeDefs = readFileSync('./computers.graphqls', {encoding: 'utf-8'}); +const resolvers = { + Query: { + computers: (_, args, context) => { + return computers; + }, + computer: (_, args, context) => { + return computers.find(p => p.id === args.id); + } + }, + Mutation: { + computers: (_, args, context) => { + return computers; + } + }, + Computer: { + errorField: (_, args, context) => { + throw new Error("Error field"); + }, + nonNullErrorField: (_, args, context) => { + return null; + } + } +} +const server = new ApolloServer({typeDefs, resolvers}); +const {url} = await startStandaloneServer(server, { + listen: {port: port}, +}); +console.log(`🚀 Computers subgraph ready at ${url}`); diff --git a/tests/defer/apollo-server/package.json b/tests/defer/apollo-server/package.json new file mode 100644 index 00000000000..57416828baf --- /dev/null +++ b/tests/defer/apollo-server/package.json @@ -0,0 +1,18 @@ +{ + "type": "module", + "name": "subgraph-computers", + "version": "1.1.0", + "description": "", + "main": "computers.js", + "scripts": { + "start": "node computers.js" + }, + "dependencies": { + "@apollo/server": "4.11.2", + "graphql": "17.0.0-alpha.9", + "patch-package": "^8.0.0" + }, + "keywords": [], + "author": "", + "license": "MIT" +} diff --git a/tests/defer/apollo-server/patches/@apollo+server+4.11.2.patch b/tests/defer/apollo-server/patches/@apollo+server+4.11.2.patch new file mode 100644 index 00000000000..a516a8c8867 --- /dev/null +++ b/tests/defer/apollo-server/patches/@apollo+server+4.11.2.patch @@ -0,0 +1,53 @@ +diff --git a/node_modules/@apollo/server/dist/esm/ApolloServer.js b/node_modules/@apollo/server/dist/esm/ApolloServer.js +index 7665490..c32a28c 100644 +--- a/node_modules/@apollo/server/dist/esm/ApolloServer.js ++++ b/node_modules/@apollo/server/dist/esm/ApolloServer.js +@@ -631,7 +631,7 @@ export const MEDIA_TYPES = { + APPLICATION_JSON_GRAPHQL_CALLBACK: 'application/json; callbackSpec=1.0; charset=utf-8', + APPLICATION_GRAPHQL_RESPONSE_JSON: 'application/graphql-response+json; charset=utf-8', + MULTIPART_MIXED_NO_DEFER_SPEC: 'multipart/mixed', +- MULTIPART_MIXED_EXPERIMENTAL: 'multipart/mixed; deferSpec=20220824', ++ MULTIPART_MIXED_EXPERIMENTAL: 'multipart/mixed; incrementalDeliverySpec=20230621', + TEXT_HTML: 'text/html', + }; + export function chooseContentTypeForSingleResultResponse(head) { +diff --git a/node_modules/@apollo/server/dist/esm/runHttpQuery.js b/node_modules/@apollo/server/dist/esm/runHttpQuery.js +index 96ef0ab..d816750 100644 +--- a/node_modules/@apollo/server/dist/esm/runHttpQuery.js ++++ b/node_modules/@apollo/server/dist/esm/runHttpQuery.js +@@ -161,9 +161,9 @@ export async function runHttpQuery({ server, httpRequest, contextValue, schemaDe + throw new BadRequestError('Apollo server received an operation that uses incremental delivery ' + + '(@defer or @stream), but the client does not accept multipart/mixed ' + + 'HTTP responses. To enable incremental delivery support, add the HTTP ' + +- "header 'Accept: multipart/mixed; deferSpec=20220824'.", { extensions: { http: { status: 406 } } }); ++ "header 'Accept: multipart/mixed; incrementalDeliverySpec=20230621'.", { extensions: { http: { status: 406 } } }); + } +- graphQLResponse.http.headers.set('content-type', 'multipart/mixed; boundary="-"; deferSpec=20220824'); ++ graphQLResponse.http.headers.set('content-type', 'multipart/mixed; boundary="-"; incrementalDeliverySpec=20230621'); + return { + ...graphQLResponse.http, + body: { +@@ -187,6 +187,7 @@ function orderExecutionResultFields(result) { + } + function orderInitialIncrementalExecutionResultFields(result) { + return { ++ ...result, + hasNext: result.hasNext, + errors: result.errors, + data: result.data, +@@ -196,6 +197,7 @@ function orderInitialIncrementalExecutionResultFields(result) { + } + function orderSubsequentIncrementalExecutionResultFields(result) { + return { ++ ...result, + hasNext: result.hasNext, + incremental: orderIncrementalResultFields(result.incremental), + extensions: result.extensions, +@@ -203,6 +205,7 @@ function orderSubsequentIncrementalExecutionResultFields(result) { + } + function orderIncrementalResultFields(incremental) { + return incremental?.map((i) => ({ ++ ...i, + hasNext: i.hasNext, + errors: i.errors, + path: i.path, diff --git a/tests/defer/build.gradle.kts b/tests/defer/build.gradle.kts index 6448f9c36d0..d90c4f5fd6d 100644 --- a/tests/defer/build.gradle.kts +++ b/tests/defer/build.gradle.kts @@ -50,6 +50,14 @@ fun configureApollo(generateKotlinModels: Boolean) { } } +apollo { + service("noTypename") { + packageName.set("defer.notypename") + srcDir("src/commonMain/graphql/noTypename") + addTypename.set("ifPolymorphic") + } +} + configureApollo(true) if (System.getProperty("idea.sync.active") == null) { registerJavaCodegenTestTask() @@ -67,12 +75,15 @@ fun com.apollographql.apollo.gradle.api.Service.configureConnection(generateKotl } tasks.withType(AbstractTestTask::class.java) { - // Run the defer with Router tests only from a specific CI job + // Run the defer with Router and defer with Apollo Server tests only from a specific CI job val runDeferWithRouterTests = System.getenv("DEFER_WITH_ROUTER_TESTS").toBoolean() - if (runDeferWithRouterTests) { - filter.setIncludePatterns("test.DeferWithRouterTest") - } else { - filter.setExcludePatterns("test.DeferWithRouterTest") - } + val runDeferWithApolloServerTests = System.getenv("DEFER_WITH_APOLLO_SERVER_TESTS").toBoolean() + filter.setIncludePatterns(*buildList { + if (runDeferWithRouterTests) add("test.DeferWithRouterTest") + if (runDeferWithApolloServerTests) add("test.DeferWithApolloServerTest") + }.toTypedArray()) + filter.setExcludePatterns(*buildList { + if (!runDeferWithRouterTests) add("test.DeferWithRouterTest") + if (!runDeferWithApolloServerTests) add("test.DeferWithApolloServerTest") + }.toTypedArray()) } - diff --git a/tests/defer/src/commonMain/graphql/base/operation.graphql b/tests/defer/src/commonMain/graphql/base/operation.graphql index fad24d09441..c3b9b8d57b9 100644 --- a/tests/defer/src/commonMain/graphql/base/operation.graphql +++ b/tests/defer/src/commonMain/graphql/base/operation.graphql @@ -109,6 +109,15 @@ query CanDeferAFragmentThatIsAlsoNotDeferredDeferredFragmentIsFirstQuery { } } +query DeferFragmentThatIsAlsoNotDeferredIsSkipped1Query { + computer(id: "Computer1") { + screen { + ...ScreenFields @defer + ...ScreenFields + } + } +} + query CanDeferAFragmentThatIsAlsoNotDeferredNotDeferredFragmentIsFirstQuery { computer(id: "Computer1") { screen { @@ -118,6 +127,15 @@ query CanDeferAFragmentThatIsAlsoNotDeferredNotDeferredFragmentIsFirstQuery { } } +query DeferFragmentThatIsAlsoNotDeferredIsSkipped2Query { + computer(id: "Computer1") { + screen { + ...ScreenFields + ...ScreenFields @defer + } + } +} + query HandlesErrorsThrownInDeferredFragmentsQuery { computer(id: "Computer1") { id @@ -150,3 +168,59 @@ query HandlesNonNullableErrorsThrownOutsideDeferredFragmentsQuery { fragment ComputerIdField on Computer { id } + +query OverlappingQuery { + computer(id: "Computer1") { + id + ... on Computer @defer(label: "a") { + id + ... on Computer @defer(label: "b") { + id + cpu + year + } + } + } +} + +query Overlapping2Query { + computer(id: "Computer1") { + id + ... on Computer @defer(label: "a") { + id + } + ... on Computer @defer(label: "b") { + id + cpu + year + } + } +} + +query SubPathQuery { + computer(id: "Computer1") { + id + } + ... on Query @defer(label: "a") { + MyFragment: __typename + computer(id: "Computer1") { + id + screen { + isColor + } + } + } +} + +query SimpleStreamQuery($initialCount: Int!) { + computers @stream(initialCount: $initialCount) { + id + } +} + +query NestedStreamQuery($initialCount: Int!) { + computers @stream(initialCount: $initialCount) { + id + peripherals @stream(initialCount: $initialCount) + } +} diff --git a/tests/defer/src/commonMain/graphql/base/schema.graphqls b/tests/defer/src/commonMain/graphql/base/schema.graphqls index 0892f0f4075..9de38fc9a65 100644 --- a/tests/defer/src/commonMain/graphql/base/schema.graphqls +++ b/tests/defer/src/commonMain/graphql/base/schema.graphqls @@ -23,9 +23,16 @@ type Computer { screen: Screen! errorField: String nonNullErrorField: String! + peripherals: [String!]! } type Screen { resolution: String! isColor: Boolean! } + +directive @stream( + label: String + if: Boolean! = true + initialCount: Int = 0 +) on FIELD diff --git a/tests/defer/src/commonMain/graphql/noTypename/operation.graphql b/tests/defer/src/commonMain/graphql/noTypename/operation.graphql new file mode 100644 index 00000000000..90ca0b72dda --- /dev/null +++ b/tests/defer/src/commonMain/graphql/noTypename/operation.graphql @@ -0,0 +1,11 @@ +query SkippingEmptyFragmentQuery { + computer(id: "Computer1") { + ... on Computer @defer(label: "a") { + ... on Computer @defer(label: "b") { + ... on Computer @defer(label: "c") { + id + } + } + } + } +} diff --git a/tests/defer/src/commonMain/graphql/noTypename/schema.graphqls b/tests/defer/src/commonMain/graphql/noTypename/schema.graphqls new file mode 100644 index 00000000000..0892f0f4075 --- /dev/null +++ b/tests/defer/src/commonMain/graphql/noTypename/schema.graphqls @@ -0,0 +1,31 @@ +type Query { + computers: [Computer!]! + computer(id: ID!): Computer +} + +type Mutation { + computers: [Computer!]! +} + +type Subscription { + count(to: Int!): Counter! +} + +type Counter { + value: Int! + valueTimesTwo: Int! +} + +type Computer { + id: ID! + cpu: String! + year: Int! + screen: Screen! + errorField: String + nonNullErrorField: String! +} + +type Screen { + resolution: String! + isColor: Boolean! +} diff --git a/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha2NormalizedCacheTest.kt similarity index 99% rename from tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt rename to tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha2NormalizedCacheTest.kt index 91bea1f6f93..9ca3d8da8b2 100644 --- a/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt +++ b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha2NormalizedCacheTest.kt @@ -45,7 +45,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertTrue -class DeferNormalizedCacheTest { +class DeferGraphQL17Alpha2NormalizedCacheTest { private lateinit var mockServer: MockServer private lateinit var apolloClient: ApolloClient private lateinit var store: ApolloStore diff --git a/tests/defer/src/commonTest/kotlin/test/DeferTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha2Test.kt similarity index 99% rename from tests/defer/src/commonTest/kotlin/test/DeferTest.kt rename to tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha2Test.kt index d04b2dc3b79..155b785f0fa 100644 --- a/tests/defer/src/commonTest/kotlin/test/DeferTest.kt +++ b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha2Test.kt @@ -25,7 +25,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class DeferTest { +class DeferGraphQL17Alpha2Test { private lateinit var mockServer: MockServer private lateinit var apolloClient: ApolloClient diff --git a/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha9NormalizedCacheTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha9NormalizedCacheTest.kt new file mode 100644 index 00000000000..992fd4eb1f8 --- /dev/null +++ b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha9NormalizedCacheTest.kt @@ -0,0 +1,579 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.api.Error.Builder +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.cache.normalized.ApolloStore +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.api.CacheHeaders +import com.apollographql.apollo.cache.normalized.api.MemoryCacheFactory +import com.apollographql.apollo.cache.normalized.apolloStore +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.apollographql.apollo.cache.normalized.optimisticUpdates +import com.apollographql.apollo.cache.normalized.store +import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.exception.ApolloNetworkException +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.network.NetworkTransport +import com.apollographql.apollo.network.http.HttpNetworkTransport +import com.apollographql.apollo.network.IncrementalDeliveryProtocol +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.assertNoRequest +import com.apollographql.mockserver.awaitRequest +import com.apollographql.mockserver.enqueueError +import com.apollographql.mockserver.enqueueMultipart +import com.apollographql.mockserver.enqueueStrings +import com.benasher44.uuid.uuid4 +import defer.SimpleDeferQuery +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.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import okio.ByteString.Companion.encodeUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class DeferGraphQL17Alpha9NormalizedCacheTest { + 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() + .networkTransport( + HttpNetworkTransport.Builder() + .serverUrl(mockServer.url()) + .incrementalDeliveryProtocol(IncrementalDeliveryProtocol.GraphQL17Alpha9) + .build() + ) + .store(store).build() + } + + private fun tearDown() { + mockServer.close() + apolloClient.close() + } + + @Test + fun cacheOnly() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheOnly).build() + + // Cache is empty + assertIs( + apolloClient.query(WithFragmentSpreadsQuery()).execute().exception + ) + + // Fill the cache by doing a network only request + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().collect() + mockServer.awaitRequest() + + // Cache is not empty, so this doesn't go to the server + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataOrThrow() + mockServer.assertNoRequest() + + // 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) + ) + ) + ), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", + ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true) + ) + ) + ), + ) + ) + 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"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().collect() + mockServer.awaitRequest() + + // Cache is not empty, but NetworkOnly still goes to the server + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null), + ) + ), + 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(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"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + + // Cache is empty, so this goes to the server + val responses = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList() + assertTrue(responses[0].exception is CacheMissException) + val networkActual = responses.drop(1).map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null), + ) + ), + 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(networkExpected, networkActual) + + // Cache is not empty, so this doesn't go to the server + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataOrThrow() + 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"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + + // Cache is empty, so this goes to the server + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null), + ) + ), + 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(networkExpected, networkActual) + + mockServer.enqueueError(statusCode = 500) + // Network will fail, so we get the cached version + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataOrThrow() + + // 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"}]},"pending":[{"id":"0","path":["computers",0]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"isColor":false},"id":"2"}],"completed":[{"id":"0"},{"id":"2"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList1) + + // Cache is empty + val responses = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList() + assertTrue(responses[0].exception is CacheMissException) + val networkActual = responses.drop(1).map { it.dataOrThrow() } + mockServer.awaitRequest() + + 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", + ScreenFields(false) + ) + ) + ) + ) + ), + ) + assertEquals(networkExpected, networkActual) + + val jsonList2 = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"0"},{"data":{"isColor":true},"id":"2"}],"completed":[{"id":"0"},{"id":"2"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList2) + + // Cache is not empty + val cacheAndNetworkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + // 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", + 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"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2","errors":[{"message":"Error field","locations":[{"line":3,"column":35}],"path":["computers",0,"screen","isColor"]}]},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + + // Cache is empty, so this goes to the server + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().drop(1) + mockServer.awaitRequest() + + val query = WithFragmentSpreadsQuery() + val uuid = uuid4() + + val networkExpected = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null), + ) + ) + ).build(), + + + ApolloResponse.Builder( + query, + uuid, + ).data( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", + ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", + ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true) + ) + ) + ), + ) + ) + ).errors( + listOf( + Builder("Error field") + .locations(listOf(Error.Location(3, 35))) + .path(listOf("computers", 0, "screen", "isColor")) + .build() + ) + ).build() + ) + assertResponseListEquals(networkExpected, networkActual) + + mockServer.enqueueError(statusCode = 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 = apolloClient.query(WithFragmentSpreadsQuery()).execute().exception + check(exception is CacheMissException) + assertIs(exception.suppressedExceptions.first()) + assertEquals("Object 'computers.0' has no field named 'cpu'", exception.message) + mockServer.awaitRequest() + } + + @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) + emit(ApolloResponse.Builder(requestUuid = uuid, operation = query) + .exception(ApolloNetworkException("Network error")) + .isLast(true) + .build() as ApolloResponse + ) + } + } + + 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 + val actual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList() + + assertResponseListEquals(networkResponses, actual.dropLast(2)) + val networkExceptionResponse = actual[actual.size - 2] + val cacheExceptionResponse = actual.last() + assertIs(networkExceptionResponse.exception) + assertIs(cacheExceptionResponse.exception) + assertEquals("Object 'computers.0.screen' has no field named 'isColor'", cacheExceptionResponse.exception!!.message) + } + + @Test + fun mutation() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0],"label":"c"},{"id":"1","path":["computers",1],"label":"c"}],"hasNext":true}""", + """{"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val networkActual = apolloClient.mutation(WithFragmentSpreadsMutation()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsMutation.Data( + listOf( + WithFragmentSpreadsMutation.Computer("Computer", "Computer1", null), + WithFragmentSpreadsMutation.Computer("Computer", "Computer2", null), + ) + ), + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", + ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ), + WithFragmentSpreadsMutation.Computer("Computer", "Computer2", + ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true) + ) + ) + ) + ) + ), + ) + assertEquals(networkExpected, networkActual) + + // Now cache is not empty + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().dataOrThrow() + + // 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) + ) + ) + ), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", + ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true) + ) + ) + ), + ) + ) + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun mutationWithOptimisticDataFails() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0],"label":"c"},{"id":"1","path":["computers",1],"label":"c"}],"hasNext":true}""", + """{"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(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) + } + + @Test + fun intermediatePayloadsAreCached() = runTest(before = { setUp() }, after = { tearDown() }) { + @Suppress("DEPRECATION") + if (com.apollographql.apollo.testing.platform() == com.apollographql.apollo.testing.Platform.Js) { + // TODO For now chunked is not supported on JS - remove this check when it is + return@runTest + } + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":false,"incremental":[{"data":{"cpu":"386"},"id":"0"},{"data":{"cpu":"486"},"id":"1"}],"completed":[{"id":"0"},{"id":"1"}]}""", + ) + val multipartBody = mockServer.enqueueMultipart("application/json") + multipartBody.enqueuePart(jsonList[0].encodeUtf8(), false) + val recordFields = apolloClient.query(SimpleDeferQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().map { + apolloClient.apolloStore.accessCache { it.loadRecord("computers.0", CacheHeaders.NONE)!!.fields }.also { + multipartBody.enqueuePart(jsonList[1].encodeUtf8(), true) + } + }.toList() + assertEquals(mapOf("__typename" to "Computer", "id" to "Computer1"), recordFields[0]) + assertEquals(mapOf("__typename" to "Computer", "id" to "Computer1", "cpu" to "386"), recordFields[1]) + } +} diff --git a/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha9Test.kt b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha9Test.kt new file mode 100644 index 00000000000..f841ab6e188 --- /dev/null +++ b/tests/defer/src/commonTest/kotlin/test/DeferGraphQL17Alpha9Test.kt @@ -0,0 +1,322 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.api.Error.Builder +import com.apollographql.apollo.autoPersistedQueryInfo +import com.apollographql.apollo.mpp.currentTimeMillis +import com.apollographql.apollo.network.http.HttpNetworkTransport +import com.apollographql.apollo.network.IncrementalDeliveryProtocol +import com.apollographql.apollo.testing.Platform +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.apollo.testing.platform +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueMultipart +import com.apollographql.mockserver.enqueueString +import com.apollographql.mockserver.enqueueStrings +import com.benasher44.uuid.uuid4 +import defer.SimpleDeferQuery +import defer.WithFragmentSpreadsQuery +import defer.WithInlineFragmentsQuery +import defer.fragment.ComputerFields +import defer.fragment.ScreenFields +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import okio.ByteString.Companion.encodeUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DeferGraphQL17Alpha9Test { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + + private suspend fun setUp() { + mockServer = MockServer() + apolloClient = ApolloClient.Builder() + .networkTransport( + HttpNetworkTransport.Builder() + .serverUrl(mockServer.url()) + .incrementalDeliveryProtocol(IncrementalDeliveryProtocol.GraphQL17Alpha9) + .build() + ) + .build() + } + + private fun tearDown() { + mockServer.close() + } + + @Test + fun deferWithFragmentSpreads() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + + val expectedDataList = listOf( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null), + ) + ), + 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) + ) + ) + ), + ) + ), + ) + + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val actualDataList = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun deferWithInlineFragments() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"b"},{"id":"3","path":["computers",1,"screen"],"label":"b"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + + val expectedDataList = listOf( + WithInlineFragmentsQuery.Data( + listOf( + WithInlineFragmentsQuery.Computer("Computer", "Computer1", null), + WithInlineFragmentsQuery.Computer("Computer", "Computer2", null), + ) + ), + WithInlineFragmentsQuery.Data( + listOf( + WithInlineFragmentsQuery.Computer("Computer", "Computer1", WithInlineFragmentsQuery.OnComputer("386", 1993, + WithInlineFragmentsQuery.Screen("Screen", "640x480", + WithInlineFragmentsQuery.OnScreen(false) + ) + ) + ), + WithInlineFragmentsQuery.Computer("Computer", "Computer2", WithInlineFragmentsQuery.OnComputer("486", 1996, + WithInlineFragmentsQuery.Screen("Screen", "800x600", + WithInlineFragmentsQuery.OnScreen(true) + ) + ) + ), + ) + ), + ) + + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val actualDataList = apolloClient.query(WithInlineFragmentsQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun deferWithFragmentSpreadsAndError() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2","errors":[{"message":"Error field","locations":[{"line":3,"column":35}],"path":["computers",0,"screen","isColor"]}]},{"id":"3"}]}""", + ) + + val query = WithFragmentSpreadsQuery() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data(WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null), + ) + ) + ).build(), + + + ApolloResponse.Builder( + query, + uuid, + ).data( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true) + ) + ) + ), + ) + ) + ).errors( + listOf( + Builder("Error field") + .locations(listOf(Error.Location(3, 35))) + .path(listOf("computers", 0, "screen", "isColor")) + .build() + ) + ).build() + ) + + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun payloadsAreReceivedIncrementally() = runTest(before = { setUp() }, after = { tearDown() }) { + @Suppress("DEPRECATION") + if (platform() == Platform.Js) { + // TODO For now chunked is not supported on JS - remove this check when it is + return@runTest + } + val delayMillis = 200L + + val multipartBody = mockServer.enqueueMultipart("application/json") + + val syncChannel = Channel() + val job = launch { + apolloClient.query(WithFragmentSpreadsQuery()).toFlow().collect { + syncChannel.send(Unit) + } + } + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + + jsonList.withIndex().forEach { (index, value) -> + multipartBody.enqueueDelay(delayMillis) + multipartBody.enqueuePart(value.encodeUtf8(), index == jsonList.lastIndex) + + val timeBeforeReceive = currentTimeMillis() + syncChannel.receive() + assertTrue(currentTimeMillis() - timeBeforeReceive >= delayMillis) + } + + job.cancel() + } + + @Test + fun emptyPayloadsAreIgnored() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonWithEmptyPayload = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"incremental":[{"data":{"cpu":"386"},"id":"0"},{"data":{"cpu":"486"},"id":"1"}],"completed":[{"id":"0"},{"id":"1"}]}""", + """{"hasNext":false}""", + ) + val jsonWithoutEmptyPayload = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":false,"incremental":[{"data":{"cpu":"386"},"id":"0"},{"data":{"cpu":"486"},"id":"1"}],"completed":[{"id":"0"},{"id":"1"}]}""", + ) + + val expectedDataList = listOf( + SimpleDeferQuery.Data( + listOf( + SimpleDeferQuery.Computer("Computer", "Computer1", null), + SimpleDeferQuery.Computer("Computer", "Computer2", null), + ) + ), + SimpleDeferQuery.Data( + listOf( + SimpleDeferQuery.Computer("Computer", "Computer1", SimpleDeferQuery.OnComputer("386")), + SimpleDeferQuery.Computer("Computer", "Computer2", SimpleDeferQuery.OnComputer("486")), + ) + ), + ) + + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonWithEmptyPayload) + var actualDataList = apolloClient.query(SimpleDeferQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonWithoutEmptyPayload) + actualDataList = apolloClient.query(SimpleDeferQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun deferWithApqFound() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder() + .autoPersistedQueries() + .build() + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val finalResponse = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().last() + assertEquals(true, finalResponse.autoPersistedQueryInfo?.hit) + assertEquals( + 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) + ) + ) + ), + ) + ), + finalResponse.dataOrThrow() + ) + } + + @Test + fun deferWithApqNotFound() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder() + .autoPersistedQueries() + .build() + + mockServer.enqueueString("""{"errors":[{"message":"PersistedQueryNotFound"}]}""") + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true}""", + """{"hasNext":true,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val finalResponse = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().last() + assertEquals(false, finalResponse.autoPersistedQueryInfo?.hit) + assertEquals( + 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) + ) + ) + ), + ) + ), + finalResponse.dataOrThrow() + ) + } +} diff --git a/tests/defer/src/commonTest/kotlin/test/DeferSubscriptionsTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferSubscriptionsTest.kt deleted file mode 100644 index 2942cac0b13..00000000000 --- a/tests/defer/src/commonTest/kotlin/test/DeferSubscriptionsTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package test - -import com.apollographql.apollo.ApolloClient -import com.apollographql.apollo.network.websocket.WebSocketNetworkTransport -import com.apollographql.apollo.testing.internal.runTest -import defer.WithFragmentSpreadsSubscription -import defer.WithInlineFragmentsSubscription -import defer.fragment.CounterFields -import kotlinx.coroutines.flow.toList -import kotlin.test.Ignore -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * This test is ignored on the CI because it requires a specific server to run. - * - * It can be manually tested by running the server from https://github.com/BoD/DeferDemo/tree/master/helix - */ -@Ignore -class DeferSubscriptionsTest { - private lateinit var apolloClient: ApolloClient - - private fun setUp() { - apolloClient = ApolloClient.Builder() - .serverUrl("http://localhost:4000/graphql") - .subscriptionNetworkTransport( - WebSocketNetworkTransport.Builder() - .serverUrl("ws://localhost:4000/graphql") - .build() - ) - .build() - } - - private fun tearDown() { - apolloClient.close() - } - - @Test - fun subscriptionWithInlineFragment() = runTest(before = { setUp() }, after = { tearDown() }) { - val expectedDataList = listOf( - // Emission 0, deferred payload 0 - WithInlineFragmentsSubscription.Data(WithInlineFragmentsSubscription.Count("Counter", 1, null)), - // Emission 0, deferred payload 1 - WithInlineFragmentsSubscription.Data(WithInlineFragmentsSubscription.Count("Counter", 1, WithInlineFragmentsSubscription.OnCounter(2))), - // Emission 1, deferred payload 0 - WithInlineFragmentsSubscription.Data(WithInlineFragmentsSubscription.Count("Counter", 2, null)), - // Emission 1, deferred payload 1 - WithInlineFragmentsSubscription.Data(WithInlineFragmentsSubscription.Count("Counter", 2, WithInlineFragmentsSubscription.OnCounter(4))), - // Emission 2, deferred payload 0 - WithInlineFragmentsSubscription.Data(WithInlineFragmentsSubscription.Count("Counter", 3, null)), - // Emission 2, deferred payload 1 - WithInlineFragmentsSubscription.Data(WithInlineFragmentsSubscription.Count("Counter", 3, WithInlineFragmentsSubscription.OnCounter(6))), - ) - - val actualDataList = apolloClient.subscription(WithInlineFragmentsSubscription()).toFlow().toList().map { it.dataOrThrow() } - assertEquals(expectedDataList, actualDataList) - } - - @Test - fun subscriptionWithFragmentSpreads() = runTest(before = { setUp() }, after = { tearDown() }) { - val expectedDataList = listOf( - // Emission 0, deferred payload 0 - WithFragmentSpreadsSubscription.Data(WithFragmentSpreadsSubscription.Count("Counter", 1, null)), - // Emission 0, deferred payload 1 - WithFragmentSpreadsSubscription.Data(WithFragmentSpreadsSubscription.Count("Counter", 1, CounterFields(2))), - // Emission 1, deferred payload 0 - WithFragmentSpreadsSubscription.Data(WithFragmentSpreadsSubscription.Count("Counter", 2, null)), - // Emission 1, deferred payload 1 - WithFragmentSpreadsSubscription.Data(WithFragmentSpreadsSubscription.Count("Counter", 2, CounterFields(4))), - // Emission 2, deferred payload 0 - WithFragmentSpreadsSubscription.Data(WithFragmentSpreadsSubscription.Count("Counter", 3, null)), - // Emission 2, deferred payload 1 - WithFragmentSpreadsSubscription.Data(WithFragmentSpreadsSubscription.Count("Counter", 3, CounterFields(6))), - ) - - val actualDataList = apolloClient.subscription(WithFragmentSpreadsSubscription()).toFlow().toList().map { it.dataOrThrow() } - assertEquals(expectedDataList, actualDataList) - } - -} diff --git a/tests/defer/src/commonTest/kotlin/test/DeferWithApolloServerTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferWithApolloServerTest.kt new file mode 100644 index 00000000000..9f74798de9e --- /dev/null +++ b/tests/defer/src/commonTest/kotlin/test/DeferWithApolloServerTest.kt @@ -0,0 +1,659 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.api.Optional +import com.apollographql.apollo.network.http.HttpNetworkTransport +import com.apollographql.apollo.network.IncrementalDeliveryProtocol +import com.apollographql.apollo.testing.internal.runTest +import com.benasher44.uuid.uuid4 +import defer.CanDeferFragmentsOnTheTopLevelQueryFieldQuery +import defer.CanDisableDeferUsingIfArgumentQuery +import defer.DeferFragmentThatIsAlsoNotDeferredIsSkipped1Query +import defer.DeferFragmentThatIsAlsoNotDeferredIsSkipped2Query +import defer.DoesNotDisableDeferWithNullIfArgumentQuery +import defer.HandlesErrorsThrownInDeferredFragmentsQuery +import defer.HandlesNonNullableErrorsThrownInDeferredFragmentsQuery +import defer.HandlesNonNullableErrorsThrownOutsideDeferredFragmentsQuery +import defer.NestedStreamQuery +import defer.Overlapping2Query +import defer.OverlappingQuery +import defer.SimpleStreamQuery +import defer.SubPathQuery +import defer.WithFragmentSpreadsMutation +import defer.WithFragmentSpreadsQuery +import defer.WithInlineFragmentsQuery +import defer.fragment.ComputerErrorField +import defer.fragment.ComputerFields +import defer.fragment.FragmentOnQuery +import defer.fragment.ScreenFields +import defer.notypename.SkippingEmptyFragmentQuery +import kotlinx.coroutines.flow.toList +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * End-to-end tests for `@defer`. + * + * These tests are not run by default (they are excluded in the gradle conf) because they expect an instance of + * [Apollo Server](https://www.apollographql.com/docs/apollo-server) running locally. + * + * They are enabled only when running from the specific `defer-with-apollo-server-tests` CI workflow. + */ +class DeferWithApolloServerTest { + private lateinit var apolloClient: ApolloClient + + private fun setUp() { + apolloClient = ApolloClient.Builder() + .networkTransport( + HttpNetworkTransport.Builder() + .serverUrl("http://127.0.0.1:4000/") + .incrementalDeliveryProtocol(IncrementalDeliveryProtocol.GraphQL17Alpha9) + .build() + ) + .build() + } + + private fun tearDown() { + apolloClient.close() + } + + @Test + fun deferWithFragmentSpreads() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true} + // {"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]} + val expectedDataList = listOf( + WithFragmentSpreadsQuery.Data( + listOf( + WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null), + WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null), + ) + ), + 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) + ) + ) + ), + ) + ), + ) + + val actualDataList = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun deferWithInlineFragments() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true} + // {"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"b"},{"id":"3","path":["computers",1,"screen"],"label":"b"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]} + val expectedDataList = listOf( + WithInlineFragmentsQuery.Data( + listOf( + WithInlineFragmentsQuery.Computer("Computer", "Computer1", null), + WithInlineFragmentsQuery.Computer("Computer", "Computer2", null), + ) + ), + WithInlineFragmentsQuery.Data( + listOf( + WithInlineFragmentsQuery.Computer("Computer", "Computer1", WithInlineFragmentsQuery.OnComputer("386", 1993, + WithInlineFragmentsQuery.Screen("Screen", "640x480", + WithInlineFragmentsQuery.OnScreen(false) + ) + ) + ), + WithInlineFragmentsQuery.Computer("Computer", "Computer2", WithInlineFragmentsQuery.OnComputer("486", 1996, + WithInlineFragmentsQuery.Screen("Screen", "800x600", + WithInlineFragmentsQuery.OnScreen(true) + ) + ) + ), + ) + ), + ) + val actualDataList = apolloClient.query(WithInlineFragmentsQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun deferWithFragmentSpreadsMutation() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0],"label":"c"},{"id":"1","path":["computers",1],"label":"c"}],"hasNext":true} + // {"hasNext":false,"pending":[{"id":"2","path":["computers",0,"screen"],"label":"a"},{"id":"3","path":["computers",1,"screen"],"label":"a"}],"incremental":[{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"id":"0"},{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"id":"1"},{"data":{"isColor":false},"id":"2"},{"data":{"isColor":true},"id":"3"}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"},{"id":"3"}]} + val expectedDataList = listOf( + WithFragmentSpreadsMutation.Data( + listOf( + WithFragmentSpreadsMutation.Computer("Computer", "Computer1", null), + WithFragmentSpreadsMutation.Computer("Computer", "Computer2", null), + ) + ), + WithFragmentSpreadsMutation.Data( + listOf( + WithFragmentSpreadsMutation.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ), + WithFragmentSpreadsMutation.Computer("Computer", "Computer2", ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true) + ) + ) + ), + ) + ), + ) + + val actualDataList = apolloClient.mutation(WithFragmentSpreadsMutation()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun canDisableDeferUsingIfArgument() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[{"__typename":"Computer","id":"Computer1","cpu":"386"},{"__typename":"Computer","id":"Computer2","cpu":"486"}]} + val expectedDataList = listOf( + CanDisableDeferUsingIfArgumentQuery.Data( + listOf( + CanDisableDeferUsingIfArgumentQuery.Computer("Computer", "Computer1", CanDisableDeferUsingIfArgumentQuery.OnComputer("386")), + CanDisableDeferUsingIfArgumentQuery.Computer("Computer", "Computer2", CanDisableDeferUsingIfArgumentQuery.OnComputer("486")), + ) + ), + ) + val actualDataList = apolloClient.query(CanDisableDeferUsingIfArgumentQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun doesNotDisableDeferWithNullIfArgument() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[{"__typename":"Computer","id":"Computer1"},{"__typename":"Computer","id":"Computer2"}]},"pending":[{"id":"0","path":["computers",0]},{"id":"1","path":["computers",1]}],"hasNext":true} + // {"hasNext":false,"incremental":[{"data":{"cpu":"386"},"id":"0"},{"data":{"cpu":"486"},"id":"1"}],"completed":[{"id":"0"},{"id":"1"}]} + val expectedDataList = listOf( + DoesNotDisableDeferWithNullIfArgumentQuery.Data( + listOf( + DoesNotDisableDeferWithNullIfArgumentQuery.Computer("Computer", "Computer1", null), + DoesNotDisableDeferWithNullIfArgumentQuery.Computer("Computer", "Computer2", null), + ) + ), + DoesNotDisableDeferWithNullIfArgumentQuery.Data( + listOf( + DoesNotDisableDeferWithNullIfArgumentQuery.Computer("Computer", "Computer1", DoesNotDisableDeferWithNullIfArgumentQuery.OnComputer("386")), + DoesNotDisableDeferWithNullIfArgumentQuery.Computer("Computer", "Computer2", DoesNotDisableDeferWithNullIfArgumentQuery.OnComputer("486")), + ) + ) + ) + val actualDataList = + apolloClient.query(DoesNotDisableDeferWithNullIfArgumentQuery(Optional.Absent)).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun canDeferFragmentsOnTheTopLevelQueryField() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"__typename":"Query"},"pending":[{"id":"0","path":[]}],"hasNext":true} + // {"hasNext":false,"incremental":[{"data":{"computers":[{"id":"Computer1"},{"id":"Computer2"}]},"id":"0"}],"completed":[{"id":"0"}]} + val expectedDataList = listOf( + CanDeferFragmentsOnTheTopLevelQueryFieldQuery.Data( + "Query", + null + ), + CanDeferFragmentsOnTheTopLevelQueryFieldQuery.Data( + "Query", + FragmentOnQuery( + listOf( + FragmentOnQuery.Computer("Computer1"), + FragmentOnQuery.Computer("Computer2"), + ) + ) + ), + ) + val actualDataList = apolloClient.query(CanDeferFragmentsOnTheTopLevelQueryFieldQuery()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun deferFragmentThatIsAlsoNotDeferredIsSkipped1() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computer":{"screen":{"__typename":"Screen","isColor":false}}}} + val expectedDataList = listOf( + DeferFragmentThatIsAlsoNotDeferredIsSkipped1Query.Data( + DeferFragmentThatIsAlsoNotDeferredIsSkipped1Query.Computer( + DeferFragmentThatIsAlsoNotDeferredIsSkipped1Query.Screen("Screen", ScreenFields(false)) + ) + ), + ) + val actualDataList = apolloClient.query(DeferFragmentThatIsAlsoNotDeferredIsSkipped1Query()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun deferFragmentThatIsAlsoNotDeferredIsSkipped2() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computer":{"screen":{"__typename":"Screen","isColor":false}}}} + val expectedDataList = listOf( + DeferFragmentThatIsAlsoNotDeferredIsSkipped2Query.Data( + DeferFragmentThatIsAlsoNotDeferredIsSkipped2Query.Computer( + DeferFragmentThatIsAlsoNotDeferredIsSkipped2Query.Screen("Screen", ScreenFields(false)) + ) + ), + ) + val actualDataList = apolloClient.query(DeferFragmentThatIsAlsoNotDeferredIsSkipped2Query()).toFlow().toList().map { it.dataOrThrow() } + assertEquals(expectedDataList, actualDataList) + } + + @Test + fun handlesErrorsThrownInDeferredFragments() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computer":{"__typename":"Computer","id":"Computer1"}},"pending":[{"id":"0","path":["computer"]}],"hasNext":true} + // {"hasNext":false,"incremental":[{"data":{"errorField":null},"errors":[{"message":"Error field","locations":[{"line":3,"column":43}],"path":["computer","errorField"],"extensions":{"code":"INTERNAL_SERVER_ERROR","stacktrace":["Error: Error field"," at Object.errorField (file:///Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/computers.js:29:19)"," at field.resolve (file:///Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/@apollo/server/dist/esm/utils/schemaInstrumentation.js:36:28)"," at executeField (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:567:20)"," at executeFields (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:476:22)"," at executeExecutionGroup (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:1855:14)"," at executor (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:1803:7)"," at pendingExecutionGroup.result (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:1825:58)"," at IncrementalGraph._onExecutionGroup (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/IncrementalGraph.js:192:33)"," at IncrementalGraph._promoteNonEmptyToRoot (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/IncrementalGraph.js:146:20)"," at IncrementalGraph.getNewRootNodes (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/IncrementalGraph.js:25:17)"]}}],"id":"0"}],"completed":[{"id":"0"}]} + val query = HandlesErrorsThrownInDeferredFragmentsQuery() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ) + .data( + HandlesErrorsThrownInDeferredFragmentsQuery.Data( + HandlesErrorsThrownInDeferredFragmentsQuery.Computer( + "Computer", "Computer1", null + ) + ) + ) + .build(), + + ApolloResponse.Builder( + query, + uuid, + ) + .data( + HandlesErrorsThrownInDeferredFragmentsQuery.Data( + HandlesErrorsThrownInDeferredFragmentsQuery.Computer( + "Computer", "Computer1", ComputerErrorField(null) + ) + ) + ) + .errors( + listOf( + Error.Builder(message = "Error field") + .path(listOf("computer", "errorField")) + .build() + ) + ) + .build(), + ) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun handlesNonNullableErrorsThrownInDeferredFragments() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computer":{"__typename":"Computer","id":"Computer1"}},"pending":[{"id":"0","path":["computer"]}],"hasNext":true} + // {"hasNext":false,"completed":[{"id":"0","errors":[{"message":"Cannot return null for non-nullable field Computer.nonNullErrorField.","locations":[{"line":3,"column":54}],"path":["computer","nonNullErrorField"]}]}]} + val query = HandlesNonNullableErrorsThrownInDeferredFragmentsQuery() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + HandlesNonNullableErrorsThrownInDeferredFragmentsQuery.Data( + HandlesNonNullableErrorsThrownInDeferredFragmentsQuery.Computer( + "Computer", "Computer1", null + ) + ) + ) + .build(), + + ApolloResponse.Builder( + query, + uuid, + ) + .data( + HandlesNonNullableErrorsThrownInDeferredFragmentsQuery.Data( + HandlesNonNullableErrorsThrownInDeferredFragmentsQuery.Computer( + "Computer", "Computer1", null + ) + ) + ) + .errors(listOf(Error.Builder(message = "Cannot return null for non-nullable field Computer.nonNullErrorField.") + .path(listOf("computer", "nonNullErrorField")).build() + ) + ) + .build(), + ) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun handlesNonNullableErrorsThrownOutsideDeferredFragments() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"errors":[{"message":"Cannot return null for non-nullable field Computer.nonNullErrorField.","locations":[{"line":1,"column":108}],"path":["computer","nonNullErrorField"],"extensions":{"code":"INTERNAL_SERVER_ERROR","stacktrace":["Error: Cannot return null for non-nullable field Computer.nonNullErrorField."," at completeValue (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:716:13)"," at executeField (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:580:23)"," at executeFields (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:476:22)"," at collectAndExecuteSubfields (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:1491:21)"," at completeObjectValue (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:1395:10)"," at completeValue (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:760:12)"," at executeField (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:580:23)"," at executeFields (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:476:22)"," at executeRootGroupedFieldSet (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:373:14)"," at executeOperation (/Users/bod/gitrepo/apollo-kotlin-0/tests/defer/apollo-server/node_modules/graphql/execution/execute.js:159:30)"]}}],"data":{"computer":null}} + val query = HandlesNonNullableErrorsThrownOutsideDeferredFragmentsQuery() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + HandlesNonNullableErrorsThrownOutsideDeferredFragmentsQuery.Data( + null + ) + ) + .errors( + listOf( + Error.Builder(message = "Cannot return null for non-nullable field Computer.nonNullErrorField.") + .path(listOf("computer", "nonNullErrorField")) + .build() + ) + ) + .build() + ) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun overlapping() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computer":{"__typename":"Computer","id":"Computer1"}},"pending":[{"id":"0","path":["computer"],"label":"b"}],"hasNext":true} + // {"hasNext":false,"incremental":[{"data":{"cpu":"386","year":1993},"id":"0"}],"completed":[{"id":"0"}]} + val query = OverlappingQuery() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + OverlappingQuery.Data( + OverlappingQuery.Computer( + "Computer", "Computer1", OverlappingQuery.OnComputer( + "Computer", "Computer1", null, + ) + ) + ) + ) + .build(), + + ApolloResponse.Builder( + query, + uuid, + ).data( + OverlappingQuery.Data( + OverlappingQuery.Computer( + "Computer", "Computer1", OverlappingQuery.OnComputer( + "Computer", "Computer1", OverlappingQuery.OnComputer1("Computer1", "386", 1993) + ) + ) + ) + ) + .build() + ) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun overlapping2() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computer":{"__typename":"Computer","id":"Computer1"}},"pending":[{"id":"0","path":["computer"],"label":"b"}],"hasNext":true} + // {"hasNext":false,"incremental":[{"data":{"cpu":"386","year":1993},"id":"0"}],"completed":[{"id":"0"}]} + val query = Overlapping2Query() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + Overlapping2Query.Data( + Overlapping2Query.Computer( + "Computer", "Computer1", Overlapping2Query.OnComputerDeferA("Computer1" + ), null + ) + ) + ) + .build(), + ApolloResponse.Builder( + query, + uuid, + ).data( + Overlapping2Query.Data( + Overlapping2Query.Computer( + "Computer", "Computer1", Overlapping2Query.OnComputerDeferA("Computer1" + ), Overlapping2Query.OnComputerDeferB( + "Computer1", "386", 1993 + ) + ) + ) + ) + .build() + ) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun subPath() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"__typename":"Query","computer":{"id":"Computer1"}},"pending":[{"id":"0","path":[],"label":"a"}],"hasNext":true} + // {"hasNext":false,"incremental":[{"data":{"screen":{"isColor":false}},"id":"0","subPath":["computer"]},{"data":{"MyFragment":"Query"},"id":"0"}],"completed":[{"id":"0"}]} + val query = SubPathQuery() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + SubPathQuery.Data( + "Query", SubPathQuery.Computer( + "Computer1" + ), null + ) + ) + .build(), + ApolloResponse.Builder( + query, + uuid, + ).data( + SubPathQuery.Data( + "Query", SubPathQuery.Computer( + "Computer1" + ), SubPathQuery.OnQuery( + "Query", SubPathQuery.Computer1( + "Computer1", + SubPathQuery.Screen(false + ) + ) + ) + ) + ) + .build() + ) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun skippingEmptyFragment() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computer":{}},"pending":[{"id":"0","path":["computer"],"label":"c"}],"hasNext":true} + // {"hasNext":false,"incremental":[{"data":{"id":"Computer1"},"id":"0"}],"completed":[{"id":"0"}]} + val query = SkippingEmptyFragmentQuery() + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + SkippingEmptyFragmentQuery.Data( + SkippingEmptyFragmentQuery.Computer( + SkippingEmptyFragmentQuery.OnComputer( + SkippingEmptyFragmentQuery.OnComputer1( + null + ) + ) + ) + ) + ) + .build(), + + ApolloResponse.Builder( + query, + uuid, + ).data( + SkippingEmptyFragmentQuery.Data( + SkippingEmptyFragmentQuery.Computer( + SkippingEmptyFragmentQuery.OnComputer( + SkippingEmptyFragmentQuery.OnComputer1( + SkippingEmptyFragmentQuery.OnComputer2( + "Computer1" + ) + ) + ) + ) + ) + ) + .build() + ) + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun simpleStream0Initial() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[]},"pending":[{"id":"0","path":["computers"]}],"hasNext":true} + // {"hasNext":false,"incremental":[{"id":"0","items":[{"id":"Computer1"},{"id":"Computer2"}]}],"completed":[{"id":"0"}]} + val query = SimpleStreamQuery(0) + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + SimpleStreamQuery.Data( + listOf() + ) + ) + .build(), + + + ApolloResponse.Builder( + query, + uuid, + ).data( + SimpleStreamQuery.Data( + listOf( + SimpleStreamQuery.Computer("Computer1"), + SimpleStreamQuery.Computer("Computer2"), + ) + ) + ) + .build() + ) + + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun simpleStream1Initial() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[{"id":"Computer1"}]},"pending":[{"id":"0","path":["computers"]}],"hasNext":true} + // {"hasNext":false,"incremental":[{"id":"0","items":[{"id":"Computer2"}]}],"completed":[{"id":"0"}]} + val query = SimpleStreamQuery(1) + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + SimpleStreamQuery.Data( + listOf( + SimpleStreamQuery.Computer("Computer1"), + ) + ) + ) + .build(), + + ApolloResponse.Builder( + query, + uuid, + ).data( + SimpleStreamQuery.Data( + listOf( + SimpleStreamQuery.Computer("Computer1"), + SimpleStreamQuery.Computer("Computer2"), + ) + ) + ) + .build() + ) + + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } + + @Test + fun nestedStream() = runTest(before = { setUp() }, after = { tearDown() }) { + // Expected payloads: + // {"data":{"computers":[{"id":"Computer1","peripherals":["Keyboard"]}]},"pending":[{"id":"0","path":["computers",0,"peripherals"]},{"id":"1","path":["computers"]}],"hasNext":true} + // {"hasNext":false,"pending":[{"id":"2","path":["computers",1,"peripherals"]}],"incremental":[{"id":"0","items":["Mouse","Printer"]},{"id":"1","items":[{"id":"Computer2","peripherals":["Keyboard"]}]},{"id":"2","items":["Mouse","Printer","Scanner"]}],"completed":[{"id":"0"},{"id":"1"},{"id":"2"}]} + val query = NestedStreamQuery(1) + val uuid = uuid4() + + val expectedDataList = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data( + NestedStreamQuery.Data( + listOf( + NestedStreamQuery.Computer("Computer1", listOf("Keyboard")) + ) + ) + ) + .build(), + + ApolloResponse.Builder( + query, + uuid, + ).data( + NestedStreamQuery.Data( + listOf( + NestedStreamQuery.Computer("Computer1", listOf("Keyboard", "Mouse", "Printer")), + NestedStreamQuery.Computer("Computer2", listOf("Keyboard", "Mouse", "Printer", "Scanner")) + ) + ) + ) + .build() + ) + + val actualResponseList = apolloClient.query(query).toFlow().toList() + assertResponseListEquals(expectedDataList, actualResponseList) + } +} diff --git a/tests/defer/src/jvmTest/kotlin/test/DeferJvmTest.kt b/tests/defer/src/jvmTest/kotlin/test/DeferJvmTest.kt index 40d36185026..2eab2f9d815 100644 --- a/tests/defer/src/jvmTest/kotlin/test/DeferJvmTest.kt +++ b/tests/defer/src/jvmTest/kotlin/test/DeferJvmTest.kt @@ -4,11 +4,11 @@ import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.cache.http.HttpFetchPolicy import com.apollographql.apollo.cache.http.httpCache import com.apollographql.apollo.cache.http.httpFetchPolicy -import com.apollographql.mockserver.MockServer -import com.apollographql.mockserver.enqueueMultipart import com.apollographql.apollo.mpp.currentTimeMillis import com.apollographql.apollo.testing.awaitElement import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueMultipart import defer.WithFragmentSpreadsQuery import defer.fragment.ComputerFields import defer.fragment.ScreenFields