From a7f5140aba7967763573333fad926c45f7084721 Mon Sep 17 00:00:00 2001 From: Oryan Date: Wed, 7 Oct 2026 08:47:03 -0400 Subject: [PATCH 1/3] Reject futures that graphql-java can't wait on The default Future wrapper only unwraps the type at scan time. At runtime graphql-java only waits on a CompletionStage, so any other Future (a FutureTask, or the observer returned by RxJava's toFuture()) reached graphql-java as the field value. Object fields then failed on their children with a source type mismatch, list fields failed with a type mismatch, and scalar fields silently serialized the future's toString. A suspend function that returned any future, CompletableFuture included, hit the same problem, because graphql-java doesn't flatten a future that completes with another future. When a resolver resolves to such a future, after its generic wrapper transformer ran, the field now fails with an error that says what to return instead. The future is never waited on, because blocking the fetching thread delays sibling fields and hangs on a future that only completes after graphql-java dispatches DataLoaders. The value is only rejected if it isn't an instance of the class the schema type was matched to at scan time. A value that implements Future but is the schema type itself, or a Future that isn't unwrapped because the default wrappers are disabled, is left alone. Because it compares against the matched class rather than the declared return type, the check also covers a return type that is a type variable and a custom transformer that returns a plain future. Fixes #203 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tools/resolver/MethodFieldResolver.kt | 56 +++++- .../graphql/kickstart/tools/ReactiveTest.kt | 189 ++++++++++++++++++ 2 files changed, 234 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index da17eb8b..b0b06a48 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -26,6 +26,8 @@ import org.slf4j.LoggerFactory import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import java.util.* +import java.util.concurrent.CompletionStage +import java.util.concurrent.Future import java.util.function.Supplier import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn import kotlin.reflect.jvm.javaType @@ -126,12 +128,15 @@ internal class MethodFieldResolver( } } + // the class the schema type was matched to, so a value that happens to implement Future isn't mistaken for a wrapper + val valueClass = TypeUtils.getRawType(getUnwrappedReturnType(), null) ?: Any::class.java + return if (numberOfParameters > 0 || isSuspendFunction) { // requires arguments and environment or is a suspend function - MethodFieldResolverDataFetcher(createSourceResolver(), this.method, args, options, isSuspendFunction) + MethodFieldResolverDataFetcher(createSourceResolver(), this.method, args, options, isSuspendFunction, valueClass) } else { // if there are no parameters an optimized version of the data fetcher can be used - LightMethodFieldResolverDataFetcher(createSourceResolver(), this.method, options) + LightMethodFieldResolverDataFetcher(createSourceResolver(), this.method, options, valueClass) } } @@ -158,13 +163,7 @@ internal class MethodFieldResolver( } override fun scanForMatches(): List { - val returnType = try { - method.kotlinFunction?.returnType?.javaType ?: method.genericReturnType - } catch (e: InternalError) { - method.genericReturnType - } - val unwrappedGenericType = genericType.unwrapGenericType(if (search.isSubscription) returnType.asPublisher() else returnType) - val returnValueMatch = TypeClassMatcher.PotentialMatch.returnValue(field.type, unwrappedGenericType, genericType, SchemaClassScanner.ReturnValueReference(method)) + val returnValueMatch = TypeClassMatcher.PotentialMatch.returnValue(field.type, getUnwrappedReturnType(), genericType, SchemaClassScanner.ReturnValueReference(method)) return field.inputValueDefinitions.mapIndexed { i, inputDefinition -> TypeClassMatcher.PotentialMatch.parameterType(inputDefinition.type, getMethodParameterType(i)!!, genericType, SchemaClassScanner.MethodParameterReference(method, i)) @@ -178,6 +177,15 @@ internal class MethodFieldResolver( ?.let { TypeUtils.parameterize(Publisher::class.java, it) } ?: this + private fun getUnwrappedReturnType(): JavaType { + val returnType = try { + method.kotlinFunction?.returnType?.javaType ?: method.genericReturnType + } catch (e: InternalError) { + method.genericReturnType + } + return genericType.unwrapGenericType(if (search.isSubscription) returnType.asPublisher() else returnType) + } + private fun getIndexOffset(): Int { return if (resolverInfo is DataClassTypeResolverInfo && !method.declaringClass.isAssignableFrom(resolverInfo.dataClassType)) { 1 @@ -207,7 +215,8 @@ internal class MethodFieldResolverDataFetcher( private val method: Method, private val args: List, private val options: SchemaParserOptions, - private val isSuspendFunction: Boolean + private val isSuspendFunction: Boolean, + private val valueClass: Class<*> ) : DataFetcher { override fun get(environment: DataFetchingEnvironment): Any? { @@ -220,9 +229,11 @@ internal class MethodFieldResolverDataFetcher( environment.coroutineScope().future(options.coroutineContextProvider.provide(), CoroutineStart.UNDISPATCHED) { ensureActive() invokeSuspend(source, method, args)?.transformWithGenericWrapper(options.genericWrappers) { environment } + .requireAwaitable(valueClass, method, isSuspendFunction = true) } } else { invoke(method, source, args)?.transformWithGenericWrapper(options.genericWrappers) { environment } + .requireAwaitable(valueClass, method, isSuspendFunction = false) } } @@ -241,13 +252,15 @@ internal class MethodFieldResolverDataFetcher( internal class LightMethodFieldResolverDataFetcher( private val sourceResolver: SourceResolver, private val method: Method, - private val options: SchemaParserOptions + private val options: SchemaParserOptions, + private val valueClass: Class<*> ) : LightDataFetcher { override fun get(fieldDefinition: GraphQLFieldDefinition?, sourceObject: Any?, environmentSupplier: Supplier): Any? { val source = sourceResolver.resolve(null, sourceObject) return invoke(method, source, emptyArray())?.transformWithGenericWrapper(options.genericWrappers, environmentSupplier) + .requireAwaitable(valueClass, method, isSuspendFunction = false) } override fun get(environment: DataFetchingEnvironment): Any? { @@ -278,6 +291,27 @@ private fun Any.transformWithGenericWrapper( return genericWrapper.transformer(this, environmentSupplier.get()) } +// graphql-java only waits on a CompletionStage returned by a data fetcher, so any other future, or a future +// that a suspend function completes with, would reach it as the field value +private fun Any?.requireAwaitable(valueClass: Class<*>, method: Method, isSuspendFunction: Boolean): Any? { + if (this !is Future<*> || valueClass.isInstance(this)) { + return this + } + + val resolverName = "${method.declaringClass.name}.${method.name}" + if (isSuspendFunction) { + throw ResolverError("Suspend function '$resolverName' resolved to a ${javaClass.name}, which graphql-java can't wait on. Return its value instead.") + } + if (this !is CompletionStage<*>) { + throw ResolverError( + "Resolver method '$resolverName' resolved to a ${javaClass.name}, which graphql-java can't wait on. " + + "Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead." + ) + } + + return this +} + private class CompareGenericWrappers { companion object : Comparator { override fun compare(w1: GenericWrapper, w2: GenericWrapper): Int = when { diff --git a/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt index 377b4639..4226f34d 100644 --- a/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt @@ -1,14 +1,18 @@ package graphql.kickstart.tools +import graphql.ExceptionWhileDataFetching +import graphql.ExecutionResult import graphql.GraphQL import graphql.execution.AsyncExecutionStrategy import graphql.kickstart.tools.SchemaParser.Companion.newParser import graphql.kickstart.tools.SchemaParserOptions.Companion.newOptions +import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Test import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.Future +import java.util.concurrent.FutureTask //import io.reactivex.Single; //import io.reactivex.internal.operators.single.SingleJust; @@ -39,6 +43,191 @@ class ReactiveTest { } } + @Test + fun `future that is not a completion stage fails with a clear error`() { + val result = GraphQL.newGraphQL(itemSchema(PlainFutureQuery())).build() + .execute("query { item { id } itemWithArgument(id: 1) { id } name }") + + assertEquals(result.getData(), mapOf("item" to null, "itemWithArgument" to null, "name" to null)) + assertEquals(result.errorMessagesByPath(), listOf("item", "itemWithArgument", "name").associateWith { + plainFutureError(PlainFutureQuery::class.java, it) + }) + } + + @Test + fun `suspend function that resolves to a future fails with a clear error`() { + val result = GraphQL.newGraphQL(itemSchema(PlainFutureQuery())).build() + .execute("query { suspendItem { id } suspendCompletableItem { id } }") + + assertEquals(result.getData(), mapOf("suspendItem" to null, "suspendCompletableItem" to null)) + assertEquals(result.errorMessagesByPath(), mapOf( + "suspendItem" to FutureTask::class.java, + "suspendCompletableItem" to CompletableFuture::class.java + ).mapValues { (name, futureClass) -> + "Suspend function '${PlainFutureQuery::class.java.name}.$name' resolved to a ${futureClass.name}, which graphql-java can't wait on. Return its value instead." + }) + } + + @Test + fun `future that is not a completion stage can be unwrapped by a generic wrapper`() { + val options = newOptions() + .genericWrappers(GenericWrapper.withTransformer(FutureTask::class, 0, { task -> task.get() })) + .build() + val gql = GraphQL.newGraphQL(itemSchema(PlainFutureQuery(), options)).build() + + val data = assertNoGraphQlErrors(gql) { + "query { item { id } itemWithArgument(id: 2) { id } name suspendItem { id } nullItem { id } }" + } + + assertEquals(data, mapOf( + "item" to mapOf("id" to "1"), + "itemWithArgument" to mapOf("id" to "2"), + "name" to "name", + "suspendItem" to mapOf("id" to "3"), + "nullItem" to null + )) + } + + @Test + fun `future that a generic wrapper or a type variable resolves to fails with a clear error`() { + val schema = newParser() + .schemaString( + """ + type Query { + item: Item + boxedItem: Item + } + + type Item { + id: ID + } + """) + .resolvers(FutureItemQuery()) + .options(newOptions() + .genericWrappers(GenericWrapper.withTransformer(Box::class, 0, { box -> FutureTask { box.value }.also { it.run() } })) + .build()) + .build() + .makeExecutableSchema() + + val result = GraphQL.newGraphQL(schema).build().execute("query { item { id } boxedItem { id } }") + + assertEquals(result.getData(), mapOf("item" to null, "boxedItem" to null)) + assertEquals(result.errorMessagesByPath(), mapOf( + "item" to plainFutureError(ItemQuery::class.java, "item"), + "boxedItem" to plainFutureError(FutureItemQuery::class.java, "boxedItem") + )) + } + + @Test + fun `value that implements Future resolves normally`() { + val schema = newParser() + .schemaString( + """ + type Query { + job: Job + } + + type Job { + id: ID + } + """) + .resolvers(object : GraphQLQueryResolver { + fun job(): Job = Job(1) + }) + .build() + .makeExecutableSchema() + + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + "query { job { id } }" + } + + assertEquals(data, mapOf("job" to mapOf("id" to "1"))) + } + + @Test + fun `future resolves as a value when no generic wrapper unwraps it`() { + val schema = newParser() + .schemaString( + """ + type Query { + task: Task + } + + type Task { + done: Boolean + } + """) + .resolvers(object : GraphQLQueryResolver { + fun task(): Future = FutureTask { "done" }.also { it.run() } + }) + .options(newOptions().useDefaultGenericWrappers(false).build()) + .build() + .makeExecutableSchema() + + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + "query { task { done } }" + } + + assertEquals(data, mapOf("task" to mapOf("done" to true))) + } + + private fun itemSchema(query: GraphQLQueryResolver, options: SchemaParserOptions = newOptions().build()) = newParser() + .schemaString( + """ + type Query { + item: Item + itemWithArgument(id: ID): Item + name: String + suspendItem: Item + suspendCompletableItem: Item + nullItem: Item + } + + type Item { + id: ID + } + """) + .resolvers(query) + .options(options) + .build() + .makeExecutableSchema() + + private fun plainFutureError(resolverClass: Class<*>, methodName: String) = + "Resolver method '${resolverClass.name}.$methodName' resolved to a ${FutureTask::class.java.name}, which graphql-java can't wait on. " + + "Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead." + + private fun ExecutionResult.errorMessagesByPath() = + errors.associate { it.path?.joinToString(".") to (it as ExceptionWhileDataFetching).exception.message } + + // the futures are already done, so even a future that could be read without blocking is rejected + private class PlainFutureQuery : GraphQLQueryResolver { + fun item(): Future = FutureTask { Item(1) }.also { it.run() } + fun itemWithArgument(id: Long): Future = FutureTask { Item(id) }.also { it.run() } + fun name(): Future = FutureTask { "name" }.also { it.run() } + suspend fun suspendItem(): Future = FutureTask { Item(3) }.also { it.run() } + suspend fun suspendCompletableItem(): Future = CompletableFuture.completedFuture(Item(4)) + fun nullItem(): Future = FutureTask { null }.also { it.run() } + } + + open class ItemQuery(private val item: () -> R) { + fun item(): R = item.invoke() + } + + private class FutureItemQuery : ItemQuery>({ FutureTask { Item(1) }.also { it.run() } }), GraphQLQueryResolver { + fun boxedItem(): Box = Box(Item(2)) + } + + private class Box(val value: T) + + private class Item(private val id: Long) + + // implements Future, but is the schema type itself rather than a wrapper around one + private class Job(private val id: Long) : Future by CompletableFuture.completedFuture("done") + private class Query : GraphQLQueryResolver { // Single> organization(int organizationid) { // return Single.just(Optional.empty()); //CompletableFuture.completedFuture(null); From 3a9092606c97d8045b6baf7cd3c5c90c7f3ad137 Mon Sep 17 00:00:00 2001 From: Oryan Date: Wed, 7 Oct 2026 09:10:12 -0400 Subject: [PATCH 2/3] Check futures of Object, e.g. for a union A method declared to return Future unwraps to Object, which every value is an instance of, so a plain future returned for a union field slipped through and graphql-java threw a TypeResolverError out of execute. When the return type unwraps to Object, every future returned by a method declared to return Future is now checked. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tools/resolver/MethodFieldResolver.kt | 23 +++++++++++-------- .../graphql/kickstart/tools/ReactiveTest.kt | 13 +++++++---- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index b0b06a48..76cee159 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -128,8 +128,10 @@ internal class MethodFieldResolver( } } - // the class the schema type was matched to, so a value that happens to implement Future isn't mistaken for a wrapper - val valueClass = TypeUtils.getRawType(getUnwrappedReturnType(), null) ?: Any::class.java + // the class the return type unwraps to, so a value that happens to implement Future isn't mistaken for a wrapper. + // Object (e.g. for a union) can't tell them apart, so then every future of a method declared to return Future is checked + val valueClass = (TypeUtils.getRawType(getUnwrappedReturnType(), null) ?: Any::class.java) + .takeUnless { it == Any::class.java && TypeUtils.getRawType(getReturnType(), null) == Future::class.java } return if (numberOfParameters > 0 || isSuspendFunction) { // requires arguments and environment or is a suspend function @@ -177,14 +179,15 @@ internal class MethodFieldResolver( ?.let { TypeUtils.parameterize(Publisher::class.java, it) } ?: this - private fun getUnwrappedReturnType(): JavaType { - val returnType = try { + private fun getReturnType(): JavaType = + try { method.kotlinFunction?.returnType?.javaType ?: method.genericReturnType } catch (e: InternalError) { method.genericReturnType } - return genericType.unwrapGenericType(if (search.isSubscription) returnType.asPublisher() else returnType) - } + + private fun getUnwrappedReturnType(): JavaType = + getReturnType().let { genericType.unwrapGenericType(if (search.isSubscription) it.asPublisher() else it) } private fun getIndexOffset(): Int { return if (resolverInfo is DataClassTypeResolverInfo && !method.declaringClass.isAssignableFrom(resolverInfo.dataClassType)) { @@ -216,7 +219,7 @@ internal class MethodFieldResolverDataFetcher( private val args: List, private val options: SchemaParserOptions, private val isSuspendFunction: Boolean, - private val valueClass: Class<*> + private val valueClass: Class<*>? ) : DataFetcher { override fun get(environment: DataFetchingEnvironment): Any? { @@ -253,7 +256,7 @@ internal class LightMethodFieldResolverDataFetcher( private val sourceResolver: SourceResolver, private val method: Method, private val options: SchemaParserOptions, - private val valueClass: Class<*> + private val valueClass: Class<*>? ) : LightDataFetcher { override fun get(fieldDefinition: GraphQLFieldDefinition?, sourceObject: Any?, environmentSupplier: Supplier): Any? { @@ -293,8 +296,8 @@ private fun Any.transformWithGenericWrapper( // graphql-java only waits on a CompletionStage returned by a data fetcher, so any other future, or a future // that a suspend function completes with, would reach it as the field value -private fun Any?.requireAwaitable(valueClass: Class<*>, method: Method, isSuspendFunction: Boolean): Any? { - if (this !is Future<*> || valueClass.isInstance(this)) { +private fun Any?.requireAwaitable(valueClass: Class<*>?, method: Method, isSuspendFunction: Boolean): Any? { + if (this !is Future<*> || valueClass?.isInstance(this) == true) { return this } diff --git a/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt index 4226f34d..49081a43 100644 --- a/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt @@ -89,15 +89,18 @@ class ReactiveTest { } @Test - fun `future that a generic wrapper or a type variable resolves to fails with a clear error`() { + fun `future that a generic wrapper, a type variable or a union resolves to fails with a clear error`() { val schema = newParser() .schemaString( """ type Query { item: Item boxedItem: Item + search: SearchResult } + union SearchResult = Item + type Item { id: ID } @@ -109,12 +112,13 @@ class ReactiveTest { .build() .makeExecutableSchema() - val result = GraphQL.newGraphQL(schema).build().execute("query { item { id } boxedItem { id } }") + val result = GraphQL.newGraphQL(schema).build().execute("query { item { id } boxedItem { id } search { ... on Item { id } } }") - assertEquals(result.getData(), mapOf("item" to null, "boxedItem" to null)) + assertEquals(result.getData(), mapOf("item" to null, "boxedItem" to null, "search" to null)) assertEquals(result.errorMessagesByPath(), mapOf( "item" to plainFutureError(ItemQuery::class.java, "item"), - "boxedItem" to plainFutureError(FutureItemQuery::class.java, "boxedItem") + "boxedItem" to plainFutureError(FutureItemQuery::class.java, "boxedItem"), + "search" to plainFutureError(FutureItemQuery::class.java, "search") )) } @@ -219,6 +223,7 @@ class ReactiveTest { private class FutureItemQuery : ItemQuery>({ FutureTask { Item(1) }.also { it.run() } }), GraphQLQueryResolver { fun boxedItem(): Box = Box(Item(2)) + fun search(): Future = FutureTask { Item(3) }.also { it.run() } } private class Box(val value: T) From 43d849ec2aeb7d239a0958c1c8f934de5c7d33ab Mon Sep 17 00:00:00 2001 From: Oryan Date: Thu, 8 Oct 2026 17:44:47 -0400 Subject: [PATCH 3/3] Format the plain future test queries and inline the expected errors Co-Authored-By: Claude Opus 5.5 --- .../graphql/kickstart/tools/ReactiveTest.kt | 114 ++++++++++-------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt index 49081a43..c35e51b3 100644 --- a/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/ReactiveTest.kt @@ -45,27 +45,47 @@ class ReactiveTest { @Test fun `future that is not a completion stage fails with a clear error`() { - val result = GraphQL.newGraphQL(itemSchema(PlainFutureQuery())).build() - .execute("query { item { id } itemWithArgument(id: 1) { id } name }") + val gql = GraphQL.newGraphQL(itemSchema(PlainFutureQuery())).build() + + val result = gql.execute( + """ + query { + item { id } + itemWithArgument(id: 1) { id } + name + } + """) assertEquals(result.getData(), mapOf("item" to null, "itemWithArgument" to null, "name" to null)) - assertEquals(result.errorMessagesByPath(), listOf("item", "itemWithArgument", "name").associateWith { - plainFutureError(PlainFutureQuery::class.java, it) - }) + assertEquals(result.errorMessagesByPath(), mapOf( + "item" to "Resolver method '${PlainFutureQuery::class.java.name}.item' resolved to a java.util.concurrent.FutureTask, " + + "which graphql-java can't wait on. Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead.", + "itemWithArgument" to "Resolver method '${PlainFutureQuery::class.java.name}.itemWithArgument' resolved to a java.util.concurrent.FutureTask, " + + "which graphql-java can't wait on. Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead.", + "name" to "Resolver method '${PlainFutureQuery::class.java.name}.name' resolved to a java.util.concurrent.FutureTask, " + + "which graphql-java can't wait on. Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead." + )) } @Test fun `suspend function that resolves to a future fails with a clear error`() { - val result = GraphQL.newGraphQL(itemSchema(PlainFutureQuery())).build() - .execute("query { suspendItem { id } suspendCompletableItem { id } }") + val gql = GraphQL.newGraphQL(itemSchema(PlainFutureQuery())).build() + + val result = gql.execute( + """ + query { + suspendItem { id } + suspendCompletableItem { id } + } + """) assertEquals(result.getData(), mapOf("suspendItem" to null, "suspendCompletableItem" to null)) assertEquals(result.errorMessagesByPath(), mapOf( - "suspendItem" to FutureTask::class.java, - "suspendCompletableItem" to CompletableFuture::class.java - ).mapValues { (name, futureClass) -> - "Suspend function '${PlainFutureQuery::class.java.name}.$name' resolved to a ${futureClass.name}, which graphql-java can't wait on. Return its value instead." - }) + "suspendItem" to "Suspend function '${PlainFutureQuery::class.java.name}.suspendItem' resolved to a java.util.concurrent.FutureTask, " + + "which graphql-java can't wait on. Return its value instead.", + "suspendCompletableItem" to "Suspend function '${PlainFutureQuery::class.java.name}.suspendCompletableItem' resolved to a java.util.concurrent.CompletableFuture, " + + "which graphql-java can't wait on. Return its value instead." + )) } @Test @@ -76,7 +96,15 @@ class ReactiveTest { val gql = GraphQL.newGraphQL(itemSchema(PlainFutureQuery(), options)).build() val data = assertNoGraphQlErrors(gql) { - "query { item { id } itemWithArgument(id: 2) { id } name suspendItem { id } nullItem { id } }" + """ + query { + item { id } + itemWithArgument(id: 2) { id } + name + suspendItem { id } + nullItem { id } + } + """ } assertEquals(data, mapOf( @@ -111,14 +139,27 @@ class ReactiveTest { .build()) .build() .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() - val result = GraphQL.newGraphQL(schema).build().execute("query { item { id } boxedItem { id } search { ... on Item { id } } }") + val result = gql.execute( + """ + query { + item { id } + boxedItem { id } + search { + ... on Item { id } + } + } + """) assertEquals(result.getData(), mapOf("item" to null, "boxedItem" to null, "search" to null)) assertEquals(result.errorMessagesByPath(), mapOf( - "item" to plainFutureError(ItemQuery::class.java, "item"), - "boxedItem" to plainFutureError(FutureItemQuery::class.java, "boxedItem"), - "search" to plainFutureError(FutureItemQuery::class.java, "search") + "item" to "Resolver method '${ItemQuery::class.java.name}.item' resolved to a java.util.concurrent.FutureTask, " + + "which graphql-java can't wait on. Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead.", + "boxedItem" to "Resolver method '${FutureItemQuery::class.java.name}.boxedItem' resolved to a java.util.concurrent.FutureTask, " + + "which graphql-java can't wait on. Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead.", + "search" to "Resolver method '${FutureItemQuery::class.java.name}.search' resolved to a java.util.concurrent.FutureTask, " + + "which graphql-java can't wait on. Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead." )) } @@ -144,41 +185,16 @@ class ReactiveTest { val gql = GraphQL.newGraphQL(schema).build() val data = assertNoGraphQlErrors(gql) { - "query { job { id } }" + """ + query { + job { id } + } + """ } assertEquals(data, mapOf("job" to mapOf("id" to "1"))) } - @Test - fun `future resolves as a value when no generic wrapper unwraps it`() { - val schema = newParser() - .schemaString( - """ - type Query { - task: Task - } - - type Task { - done: Boolean - } - """) - .resolvers(object : GraphQLQueryResolver { - fun task(): Future = FutureTask { "done" }.also { it.run() } - }) - .options(newOptions().useDefaultGenericWrappers(false).build()) - .build() - .makeExecutableSchema() - - val gql = GraphQL.newGraphQL(schema).build() - - val data = assertNoGraphQlErrors(gql) { - "query { task { done } }" - } - - assertEquals(data, mapOf("task" to mapOf("done" to true))) - } - private fun itemSchema(query: GraphQLQueryResolver, options: SchemaParserOptions = newOptions().build()) = newParser() .schemaString( """ @@ -200,10 +216,6 @@ class ReactiveTest { .build() .makeExecutableSchema() - private fun plainFutureError(resolverClass: Class<*>, methodName: String) = - "Resolver method '${resolverClass.name}.$methodName' resolved to a ${FutureTask::class.java.name}, which graphql-java can't wait on. " + - "Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead." - private fun ExecutionResult.errorMessagesByPath() = errors.associate { it.path?.joinToString(".") to (it as ExceptionWhileDataFetching).exception.message }