From 15234602cd861d5866c501dac0a615e8a921ab18 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 16:18:08 -0400 Subject: [PATCH 01/10] Replay the matcher's unwrapping in the data fetcher ReturnValueUnwrapper worked out where to unwrap a return value with its own copy of the order TypeClassMatcher unwraps in. The matcher now records what it unwraps on the way to the matched type (generic wrappers, DataFetcherResult, Optional, list elements), the method's return value is matched from its declared type so the record starts at the outermost wrapper, and the data fetcher builds its steps from that record. That also unwraps a wrapper that only a custom schemaWrapper introduces, such as one whose schemaWrapper is List>. The copy only looked for wrappers in the declared type's arguments. --- .../kickstart/tools/GenericTypeResolver.kt | 4 +- .../kickstart/tools/TypeClassMatcher.kt | 151 ++++++++++-------- .../tools/resolver/MethodFieldResolver.kt | 14 +- .../tools/resolver/ReturnValueUnwrapper.kt | 62 +------ .../tools/NestedGenericWrapperTest.kt | 24 ++- 5 files changed, 124 insertions(+), 131 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/GenericTypeResolver.kt b/src/main/kotlin/graphql/kickstart/tools/GenericTypeResolver.kt index 41e2fde9..4ad02eb8 100644 --- a/src/main/kotlin/graphql/kickstart/tools/GenericTypeResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/GenericTypeResolver.kt @@ -29,7 +29,7 @@ internal class GenericTypeResolver(private val containingType: JavaType, private * - wildcards are replaced by their upper bound, e.g. `? extends Foo` becomes `Foo` * - primitives are replaced by their boxed class, e.g. `int` becomes `Integer` */ - fun resolve(javaType: JavaType, onUnwrap: ((wrapped: ParameterizedType) -> Unit)? = null): JavaType { + fun resolve(javaType: JavaType, onUnwrap: ((SchemaParserOptions.GenericWrapper) -> Unit)? = null): JavaType { return when (val type = resolveTypeVariables(javaType)) { is ParameterizedType -> { val rawType = type.rawType @@ -41,7 +41,7 @@ internal class GenericTypeResolver(private val containingType: JavaType, private throw IndexOutOfBoundsException("Generic type '${TypeUtils.toString(type)}' does not have a type argument at index ${wrapper.index}!") } - onUnwrap?.invoke(type) + onUnwrap?.invoke(wrapper) val unwrapsTo = wrapper.schemaWrapper.invoke(typeArguments[wrapper.index]) resolve(unwrapsTo, onUnwrap) } diff --git a/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt b/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt index a4244214..ec2212fd 100644 --- a/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt +++ b/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt @@ -1,6 +1,7 @@ package graphql.kickstart.tools import graphql.execution.DataFetcherResult +import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper import graphql.kickstart.tools.util.GraphQLLangType import graphql.kickstart.tools.util.JavaType import graphql.kickstart.tools.util.isSubtypeOf @@ -15,79 +16,22 @@ import java.util.* */ internal class TypeClassMatcher(private val definitionsByName: Map>) { - private fun error(potentialMatch: PotentialMatch, msg: String) = SchemaClassScannerError("Unable to match type definition (${TypeUtil.simplePrint(potentialMatch.graphQLType)}) for reference ${potentialMatch.reference} with java type (${potentialMatch.javaType}): $msg") - fun match(potentialMatch: PotentialMatch): Match { - return match(potentialMatch, potentialMatch.graphQLType, potentialMatch.javaType, true) - } - - private fun match(potentialMatch: PotentialMatch, graphQLType: GraphQLLangType, javaType: JavaType, root: Boolean = false): Match { - - var realType = potentialMatch.typeResolver.resolve(javaType) - - if (realType is ParameterizedType && realType.isSubtypeOf(DataFetcherResult::class.java)) { - if (potentialMatch.location != Location.RETURN_TYPE) { - throw error(potentialMatch, "${DataFetcherResult::class.java.name} can only be used as a return type") - } - - realType = potentialMatch.typeResolver.resolve(realType.actualTypeArguments.first()) - - if (realType is ParameterizedType && realType.isSubtypeOf(DataFetcherResult::class.java)) { - throw error(potentialMatch, "${DataFetcherResult::class.java.name} cannot be nested within itself") - } - } - - var optional = false - - // Handle jdk8 Optionals - if (realType is ParameterizedType && realType.isSubtypeOf(Optional::class.java)) { - optional = true - - if (potentialMatch.location == Location.RETURN_TYPE && !root) { - throw error(potentialMatch, "${Optional::class.java.name} can only be used at the top level of a return type") - } - - realType = potentialMatch.typeResolver.resolve(realType.actualTypeArguments.first()) - - if (realType is ParameterizedType && realType.isSubtypeOf(Optional::class.java)) { - throw error(potentialMatch, "${Optional::class.java.name} cannot be nested within itself") - } - } - - // Match graphql type to java type. - return when (graphQLType) { - is NonNullType -> { - if (optional) { - throw error(potentialMatch, "graphql type is marked as nonnull but ${Optional::class.java.name} was used") - } - match(potentialMatch, graphQLType.type, realType) - } - - is ListType -> when { - realType is ParameterizedType && realType.isSubtypeOf(Iterable::class.java) -> - match(potentialMatch, graphQLType.type, realType.actualTypeArguments.first()) - realType is Class<*> && realType.isArray -> - match(potentialMatch, graphQLType.type, realType.componentType) - // e.g. a property map value: the element type is unknown, so match it as Object too - realType == Any::class.java -> - match(potentialMatch, graphQLType.type, realType) - else -> - throw error(potentialMatch, "Java class is not a List or generic type information was lost: $realType") - } - + val unwrapping = potentialMatch.unwrapping + return when (val graphQLType = unwrapping.graphQLType) { is TypeName -> { val typeDefinition = ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS[graphQLType.name] ?: definitionsByName[graphQLType.name] - ?: throw error(potentialMatch, "No ${TypeDefinition::class.java.simpleName} for type name ${graphQLType.name}") + ?: throw potentialMatch.error("No ${TypeDefinition::class.java.simpleName} for type name ${graphQLType.name}") if (typeDefinition is ScalarTypeDefinition) { ScalarMatch(typeDefinition) } else { - ValidMatch(typeDefinition, realType, potentialMatch.reference) + ValidMatch(typeDefinition, unwrapping.javaType, potentialMatch.reference) } } - is TypeDefinition<*> -> ValidMatch(graphQLType, realType, potentialMatch.reference) - else -> throw error(potentialMatch, "Unknown type: ${realType.javaClass.name}") + is TypeDefinition<*> -> ValidMatch(graphQLType, unwrapping.javaType, potentialMatch.reference) + else -> throw potentialMatch.error("Unknown type: ${unwrapping.javaType.javaClass.name}") } } @@ -109,6 +53,75 @@ internal class TypeClassMatcher(private val definitionsByName: Map): Unwrapping { + val onUnwrap = { wrapper: GenericWrapper -> unwrapped += Unwrapped.Wrapper(wrapper) } + var realType = typeResolver.resolve(javaType, onUnwrap) + + if (realType is ParameterizedType && realType.isSubtypeOf(DataFetcherResult::class.java)) { + if (location != Location.RETURN_TYPE) { + throw error("${DataFetcherResult::class.java.name} can only be used as a return type") + } + + unwrapped += Unwrapped.Data + realType = typeResolver.resolve(realType.actualTypeArguments.first(), onUnwrap) + + if (realType is ParameterizedType && realType.isSubtypeOf(DataFetcherResult::class.java)) { + throw error("${DataFetcherResult::class.java.name} cannot be nested within itself") + } + } + + var optional = false + + // Handle jdk8 Optionals + if (realType is ParameterizedType && realType.isSubtypeOf(Optional::class.java)) { + optional = true + + if (location == Location.RETURN_TYPE && !root) { + throw error("${Optional::class.java.name} can only be used at the top level of a return type") + } + + unwrapped += Unwrapped.Present + realType = typeResolver.resolve(realType.actualTypeArguments.first(), onUnwrap) + + if (realType is ParameterizedType && realType.isSubtypeOf(Optional::class.java)) { + throw error("${Optional::class.java.name} cannot be nested within itself") + } + } + + // Match graphql type to java type. + return when (graphQLType) { + is NonNullType -> { + if (optional) { + throw error("graphql type is marked as nonnull but ${Optional::class.java.name} was used") + } + unwrap(graphQLType.type, realType, root = false, unwrapped) + } + + is ListType -> when { + realType is ParameterizedType && realType.isSubtypeOf(Iterable::class.java) -> { + unwrapped += Unwrapped.Elements + unwrap(graphQLType.type, realType.actualTypeArguments.first(), root = false, unwrapped) + } + realType is Class<*> && realType.isArray -> + unwrap(graphQLType.type, realType.componentType, root = false, unwrapped) + // e.g. a property map value: the element type is unknown, so match it as Object too + realType == Any::class.java -> + unwrap(graphQLType.type, realType, root = false, unwrapped) + else -> + throw error("Java class is not a List or generic type information was lost: $realType") + } + + else -> Unwrapping(unwrapped, graphQLType, realType) + } + } + companion object { fun returnValue(graphQLType: GraphQLLangType, javaType: JavaType, typeResolver: GenericTypeResolver, reference: ScanDictionary.Reference) = PotentialMatch(graphQLType, javaType, typeResolver, reference, Location.RETURN_TYPE) @@ -117,4 +130,16 @@ internal class TypeClassMatcher(private val definitionsByName: Map, val graphQLType: GraphQLLangType, val javaType: JavaType) + + internal sealed interface Unwrapped { + class Wrapper(val wrapper: GenericWrapper) : Unwrapped + object Data : Unwrapped + object Present : Unwrapped + object Elements : Unwrapped + } } diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index a1a7cf41..d3855542 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -131,7 +131,7 @@ internal class MethodFieldResolver( val valueClass = (TypeUtils.getRawType(getResolvedReturnType(), null) ?: Any::class.java) .takeUnless { it == Any::class.java && TypeUtils.getRawType(returnType, null) == Future::class.java } val unwrapper = ReturnValueUnwrapper.create( - method, returnType, valueClass, field.type, search.isSubscription, typeResolver, options.genericWrappers, scalarNames + method, returnValueMatch.unwrapping.unwrapped, valueClass, field.type, search.isSubscription, options.genericWrappers, scalarNames ) return if (numberOfParameters > 0 || isSuspendFunction) { @@ -160,8 +160,6 @@ internal class MethodFieldResolver( } override fun scanForMatches(): List { - val returnValueMatch = TypeClassMatcher.PotentialMatch.returnValue(field.type, getResolvedReturnType(), typeResolver, ScanDictionary.ReturnValueReference(method)) - return field.inputValueDefinitions.mapIndexed { i, inputDefinition -> TypeClassMatcher.PotentialMatch.parameterType(inputDefinition.type, getMethodParameterType(i)!!, typeResolver, ScanDictionary.MethodParameterReference(method, i)) } + listOf(returnValueMatch) @@ -184,8 +182,14 @@ internal class MethodFieldResolver( typeResolver.resolveTypeVariables(declaredType) } - private fun getResolvedReturnType(): JavaType = - typeResolver.resolve(if (search.isSubscription) returnType.asPublisher() else returnType) + // the declared type, so the match also covers the wrappers of the value the method returns + private val returnValueMatch by lazy { + TypeClassMatcher.PotentialMatch.returnValue(field.type, matchedReturnType(), typeResolver, ScanDictionary.ReturnValueReference(method)) + } + + private fun matchedReturnType(): JavaType = if (search.isSubscription) returnType.asPublisher() else returnType + + private fun getResolvedReturnType(): JavaType = typeResolver.resolve(matchedReturnType()) private fun getJavaMethodParameterIndex(index: Int) = index + search.firstArgumentIndex diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index 7e1cf216..cb6c112e 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -1,21 +1,15 @@ package graphql.kickstart.tools.resolver import graphql.execution.DataFetcherResult -import graphql.kickstart.tools.GenericTypeResolver import graphql.kickstart.tools.ResolverError import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper +import graphql.kickstart.tools.TypeClassMatcher.Unwrapped import graphql.kickstart.tools.util.GraphQLLangType -import graphql.kickstart.tools.util.JavaType -import graphql.kickstart.tools.util.isSubtypeOf import graphql.kickstart.tools.util.unwrap -import graphql.language.ListType -import graphql.language.NonNullType import graphql.schema.DataFetchingEnvironment import graphql.schema.idl.ScalarInfo import kotlinx.coroutines.future.asDeferred import java.lang.reflect.Method -import java.lang.reflect.ParameterizedType -import java.lang.reflect.WildcardType import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage @@ -47,20 +41,18 @@ internal class ReturnValueUnwrapper private constructor( companion object { fun create( method: Method, - returnType: JavaType, + unwrapped: List, valueClass: Class<*>?, fieldType: GraphQLLangType, isSubscription: Boolean, - typeResolver: GenericTypeResolver, genericWrappers: List, scalarNames: Set ): ReturnValueUnwrapper { val nested = when { // graphql-java completes a subscription's events one at a time isSubscription -> null - !hasGenericWrapperBelow(returnType, genericWrappers) -> null fieldType.unwrap().name.let { it in scalarNames && it !in ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS } -> null - else -> nestedSteps(fieldType, returnType, typeResolver, genericWrappers) + else -> nestedSteps(unwrapped, genericWrappers) } return ReturnValueUnwrapper(genericWrappers, method, valueClass, nested) } @@ -96,44 +88,13 @@ private class ValueOf(private val present: Step) : Step { if (value is Optional<*>) present.unwrap(value.orElse(null), environmentSupplier) else value } -private sealed interface Unwrapped { - class Wrapper(rawType: Class<*>) : Unwrapped { - val isFuture = Future::class.java.isAssignableFrom(rawType) || CompletionStage::class.java.isAssignableFrom(rawType) - } - - object Data : Unwrapped - object Present : Unwrapped - object Elements : Unwrapped -} - -private fun nestedSteps(fieldType: GraphQLLangType, returnType: JavaType, typeResolver: GenericTypeResolver, genericWrappers: List): Step? { - val unwrapped = mutableListOf() - val onUnwrap = { wrapped: ParameterizedType -> unwrapped += Unwrapped.Wrapper(wrapped.rawType as Class<*>) } - - fun addLevel(graphQLType: GraphQLLangType, javaType: JavaType) { - var type = typeResolver.resolve(javaType, onUnwrap) - if (type is ParameterizedType && type.isSubtypeOf(DataFetcherResult::class.java)) { - unwrapped += Unwrapped.Data - type = typeResolver.resolve(type.actualTypeArguments.first(), onUnwrap) - } - if (type is ParameterizedType && type.isSubtypeOf(Optional::class.java)) { - unwrapped += Unwrapped.Present - type = typeResolver.resolve(type.actualTypeArguments.first(), onUnwrap) - } - when (graphQLType) { - is NonNullType -> addLevel(graphQLType.type, type) - is ListType -> if (type is ParameterizedType && type.isSubtypeOf(Iterable::class.java)) { - unwrapped += Unwrapped.Elements - addLevel(graphQLType.type, type.actualTypeArguments.first()) - } - else -> Unit - } - } +private val Unwrapped.isFuture + get() = this is Unwrapped.Wrapper && (Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type)) +private fun nestedSteps(unwrapped: List, genericWrappers: List): Step? { fun isFutureReturnedByWrapperBefore(i: Int) = - (unwrapped[i] as? Unwrapped.Wrapper)?.isFuture == true && (unwrapped.getOrNull(i - 1) as? Unwrapped.Wrapper)?.isFuture == false + unwrapped[i].isFuture && unwrapped.getOrNull(i - 1).let { it is Unwrapped.Wrapper && !it.isFuture } - addLevel(fieldType, typeResolver.resolve(returnType, onUnwrap)) val awaited = unwrapped.filterIndexed { i, _ -> !isFutureReturnedByWrapperBefore(i) } val belowOutermost = if (awaited.firstOrNull() is Unwrapped.Wrapper) awaited.drop(1) else awaited return belowOutermost.foldRight(null as Step?) { it, next -> @@ -146,15 +107,6 @@ private fun nestedSteps(fieldType: GraphQLLangType, returnType: JavaType, typeRe } } -private fun hasGenericWrapperBelow(type: JavaType, genericWrappers: List): Boolean = - type is ParameterizedType && type.actualTypeArguments.any { it.mentionsGenericWrapper(genericWrappers) } - -private fun JavaType.mentionsGenericWrapper(genericWrappers: List): Boolean = when (this) { - is ParameterizedType -> genericWrappers.any { it.type == rawType } || actualTypeArguments.any { it.mentionsGenericWrapper(genericWrappers) } - is WildcardType -> upperBounds.any { it.mentionsGenericWrapper(genericWrappers) } - else -> false -} - private fun Any?.then(next: Step?, environmentSupplier: Supplier): Any? = if (next == null) this else then { next.unwrap(it, environmentSupplier) } diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index 0684b81f..280f04fb 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -136,6 +136,9 @@ class NestedGenericWrapperTest { )) } + @Test + fun `unwraps the wrappers a schemaWrapper introduces`() = assertResolves("batchOfPayloads", ab) + @Test fun `gives a custom scalar the wrappers as they are`() { val json = mutableListOf() @@ -179,17 +182,19 @@ class NestedGenericWrapperTest { val shapes = (1..4).fold(listOf(listOf>())) { shapes, _ -> shapes + shapes.filter { it.size == shapes.last().size }.flatMap { shape -> containers.map { shape + it } } } val javaTypes = shapes.flatMap { shape -> listOf(shape to javaType(shape, wildcardElements = false), shape to javaType(shape, wildcardElements = true)) } - val accepted = javaTypes.flatMap { (shape, javaType) -> graphQLTypes.map { Triple(shape, javaType, it) } }.filter { (_, javaType, graphQLType) -> + val accepted = javaTypes.flatMap { (shape, javaType) -> graphQLTypes.map { Triple(shape, javaType, it) } }.mapNotNull { (shape, javaType, graphQLType) -> + val potentialMatch = TypeClassMatcher.PotentialMatch.returnValue(graphQLType, javaType, typeResolver, ScanDictionary.ReturnValueReference(method)) val match = try { - matcher.match(TypeClassMatcher.PotentialMatch.returnValue(graphQLType, typeResolver.resolve(javaType), typeResolver, ScanDictionary.ReturnValueReference(method))) + matcher.match(potentialMatch) } catch (e: SchemaClassScannerError) { null } - (match as? TypeClassMatcher.ValidMatch)?.javaType == Payload::class.java + Triple(shape, potentialMatch, graphQLType).takeIf { (match as? TypeClassMatcher.ValidMatch)?.javaType == Payload::class.java } } - val failures = accepted.mapNotNull { (shape, javaType, graphQLType) -> + val failures = accepted.mapNotNull { (shape, potentialMatch, graphQLType) -> + val javaType = potentialMatch.javaType val unwrapper = ReturnValueUnwrapper.create( - method, javaType, TypeUtils.getRawType(typeResolver.resolve(javaType), null), graphQLType, false, typeResolver, options.genericWrappers, emptySet() + method, potentialMatch.unwrapping.unwrapped, TypeUtils.getRawType(typeResolver.resolve(javaType), null), graphQLType, false, options.genericWrappers, emptySet() ) val result = GraphQL.newGraphQL(fieldSchema(graphQLType) { environment -> unwrapper.unwrap(value(shape)) { environment } }).build().execute("query { field { data } }") "${TypeUtils.toString(javaType)} as ${AstPrinter.printAst(graphQLType)}: ${result.errors.map { it.message }} ${result.getData()}" @@ -263,6 +268,7 @@ class NestedGenericWrapperTest { listWithFailedWrapper: [Payload] futureOfListWithFailedWrapper: [Payload] listOfPlainFutures: [Payload] + batchOfPayloads: [Payload!]! plainList: [Payload!]! futureOfList: [Payload!]! wrappedFuture: Payload! @@ -277,7 +283,7 @@ class NestedGenericWrapperTest { """) .resolvers(query) .scalars(*scalars) - .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper).build()) + .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper, batchWrapper).build()) .build() .makeExecutableSchema() @@ -366,11 +372,17 @@ class NestedGenericWrapperTest { fun listOfPlainFutures(): List> = listOf(FutureTask { Payload("a") }.apply { run() }) fun json(): List>> = listOf(Try.success(mapOf("a" to 1)), Try.success(mapOf("b" to 2))) fun futureJson(): CompletableFuture>> = completedFuture(Try.success(mapOf("c" to 3))) + fun batchOfPayloads(): Batch = Batch(listOfFutures()) } + class Batch(val items: List>) + companion object { private val tryWrapper = GenericWrapper.withTransformer(Try::class, 0, { wrapper -> wrapper.get() }) private val pageWrapper = GenericWrapper.listCollectionWithTransformer(Page::class, 0, { page -> page.content }) + private val batchWrapper = GenericWrapper.withTransformer(Batch::class, 0, { batch -> batch.items }) { item -> + ParameterizedTypeImpl(List::class.java, arrayOf(ParameterizedTypeImpl(CompletableFuture::class.java, arrayOf(item), null)), null) + } private val payloadType = GraphQLObjectType.newObject() .name("Payload") .field(GraphQLFieldDefinition.newFieldDefinition().name("data").type(GraphQLNonNull.nonNull(Scalars.GraphQLString))) From 4e5cf3ef813f216396029a0de03b00c61439114b Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 16:19:14 -0400 Subject: [PATCH 02/10] Unwrap nested wrappers for custom scalars too Custom scalars were left out of the nested unwrapping, so a Coercing kept getting what it got before: [DateTime] from List> passed the schema build and then handed the futures to the scalar. A custom scalar now gets the values the schema build matched it to, like every other type. --- .../tools/resolver/MethodFieldResolver.kt | 4 +- .../tools/resolver/ReturnValueUnwrapper.kt | 21 +----- .../tools/NestedGenericWrapperTest.kt | 71 ++++++++++++------- 3 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index d3855542..6b079bd5 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -130,9 +130,7 @@ internal class MethodFieldResolver( // Object (e.g. for a union) can't tell them apart, so then every future of a method whose return type is Future is checked val valueClass = (TypeUtils.getRawType(getResolvedReturnType(), null) ?: Any::class.java) .takeUnless { it == Any::class.java && TypeUtils.getRawType(returnType, null) == Future::class.java } - val unwrapper = ReturnValueUnwrapper.create( - method, returnValueMatch.unwrapping.unwrapped, valueClass, field.type, search.isSubscription, options.genericWrappers, scalarNames - ) + val unwrapper = ReturnValueUnwrapper.create(method, returnValueMatch.unwrapping.unwrapped, valueClass, search.isSubscription, options.genericWrappers) return if (numberOfParameters > 0 || isSuspendFunction) { // requires arguments and environment or is a suspend function diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index cb6c112e..d04fef35 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -4,10 +4,7 @@ import graphql.execution.DataFetcherResult import graphql.kickstart.tools.ResolverError import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper import graphql.kickstart.tools.TypeClassMatcher.Unwrapped -import graphql.kickstart.tools.util.GraphQLLangType -import graphql.kickstart.tools.util.unwrap import graphql.schema.DataFetchingEnvironment -import graphql.schema.idl.ScalarInfo import kotlinx.coroutines.future.asDeferred import java.lang.reflect.Method import java.util.* @@ -39,21 +36,9 @@ internal class ReturnValueUnwrapper private constructor( .then(nested, environmentSupplier) companion object { - fun create( - method: Method, - unwrapped: List, - valueClass: Class<*>?, - fieldType: GraphQLLangType, - isSubscription: Boolean, - genericWrappers: List, - scalarNames: Set - ): ReturnValueUnwrapper { - val nested = when { - // graphql-java completes a subscription's events one at a time - isSubscription -> null - fieldType.unwrap().name.let { it in scalarNames && it !in ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS } -> null - else -> nestedSteps(unwrapped, genericWrappers) - } + fun create(method: Method, unwrapped: List, valueClass: Class<*>?, isSubscription: Boolean, genericWrappers: List): ReturnValueUnwrapper { + // graphql-java completes a subscription's events one at a time + val nested = if (isSubscription) null else nestedSteps(unwrapped, genericWrappers) return ReturnValueUnwrapper(genericWrappers, method, valueClass, nested) } } diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index 280f04fb..6dbd711b 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -11,13 +11,16 @@ import graphql.execution.DefaultValueUnboxer import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper import graphql.kickstart.tools.resolver.ReturnValueUnwrapper import graphql.kickstart.tools.util.ParameterizedTypeImpl +import graphql.kickstart.tools.util.unwrap import graphql.language.AstPrinter import graphql.language.ListType import graphql.language.NonNullType import graphql.language.ObjectTypeDefinition +import graphql.language.ScalarTypeDefinition import graphql.language.Type import graphql.language.TypeName import graphql.schema.Coercing +import graphql.schema.CoercingSerializeException import graphql.schema.DataFetcher import graphql.schema.DataFetchingEnvironment import graphql.schema.DataFetchingEnvironmentImpl @@ -140,13 +143,14 @@ class NestedGenericWrapperTest { fun `unwraps the wrappers a schemaWrapper introduces`() = assertResolves("batchOfPayloads", ab) @Test - fun `gives a custom scalar the wrappers as they are`() { + fun `gives a custom scalar the unwrapped values`() { val json = mutableListOf() val schema = schema(Query(), jsonScalar(json)) assertEquals(execute("json", schema = schema, selection = "").errors, listOf()) assertEquals(execute("futureJson", schema = schema, selection = "").errors, listOf()) - assertEquals(json.map { it?.javaClass }, listOf(Try::class.java, Try::class.java, Try::class.java)) + assertEquals(execute("jsonFutures", schema = schema, selection = "").errors, listOf()) + assertEquals(json, listOf(mapOf("a" to 1), mapOf("b" to 2), mapOf("c" to 3), mapOf("d" to 4), mapOf("e" to 5))) } @Test @@ -174,44 +178,48 @@ class NestedGenericWrapperTest { fun `resolves every return type the matcher accepts`() { val options = SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper).build() val typeResolver = GenericTypeResolver(Query::class.java, options) - val matcher = TypeClassMatcher(mapOf("Payload" to ObjectTypeDefinition("Payload"))) + val matcher = TypeClassMatcher(mapOf("Payload" to ObjectTypeDefinition("Payload"), "Leaf" to ScalarTypeDefinition("Leaf"))) val method = Query::class.java.getMethod("listOfWrapped") - val payload = TypeName("Payload") - val graphQLTypes = listOf(payload, NonNullType(payload), ListType(payload), NonNullType(ListType(NonNullType(payload))), ListType(ListType(payload))) + val graphQLTypes = listOf(TypeName("Payload"), TypeName("Leaf")).flatMap { listOf(it, NonNullType(it), ListType(it), NonNullType(ListType(NonNullType(it))), ListType(ListType(it))) } val containers = listOf(List::class.java, CompletableFuture::class.java, Try::class.java, Page::class.java, DataFetcherResult::class.java, Optional::class.java) val shapes = (1..4).fold(listOf(listOf>())) { shapes, _ -> shapes + shapes.filter { it.size == shapes.last().size }.flatMap { shape -> containers.map { shape + it } } } - val javaTypes = shapes.flatMap { shape -> listOf(shape to javaType(shape, wildcardElements = false), shape to javaType(shape, wildcardElements = true)) } - - val accepted = javaTypes.flatMap { (shape, javaType) -> graphQLTypes.map { Triple(shape, javaType, it) } }.mapNotNull { (shape, javaType, graphQLType) -> - val potentialMatch = TypeClassMatcher.PotentialMatch.returnValue(graphQLType, javaType, typeResolver, ScanDictionary.ReturnValueReference(method)) - val match = try { - matcher.match(potentialMatch) - } catch (e: SchemaClassScannerError) { - null + + val accepted = graphQLTypes.flatMap { graphQLType -> + shapes.flatMap { shape -> listOf(false, true).map { shape to javaType(shape, leaf(graphQLType).javaClass, wildcardElements = it) } }.mapNotNull { (shape, javaType) -> + val potentialMatch = TypeClassMatcher.PotentialMatch.returnValue(graphQLType, javaType, typeResolver, ScanDictionary.ReturnValueReference(method)) + val match = try { + matcher.match(potentialMatch) + } catch (e: SchemaClassScannerError) { + null + } + (shape to potentialMatch).takeIf { match != null && potentialMatch.unwrapping.javaType == leaf(graphQLType).javaClass } } - Triple(shape, potentialMatch, graphQLType).takeIf { (match as? TypeClassMatcher.ValidMatch)?.javaType == Payload::class.java } } - val failures = accepted.mapNotNull { (shape, potentialMatch, graphQLType) -> - val javaType = potentialMatch.javaType + val failures = accepted.mapNotNull { (shape, potentialMatch) -> + val graphQLType = potentialMatch.graphQLType val unwrapper = ReturnValueUnwrapper.create( - method, potentialMatch.unwrapping.unwrapped, TypeUtils.getRawType(typeResolver.resolve(javaType), null), graphQLType, false, options.genericWrappers, emptySet() + method, potentialMatch.unwrapping.unwrapped, TypeUtils.getRawType(typeResolver.resolve(potentialMatch.javaType), null), false, options.genericWrappers ) - val result = GraphQL.newGraphQL(fieldSchema(graphQLType) { environment -> unwrapper.unwrap(value(shape)) { environment } }).build().execute("query { field { data } }") - "${TypeUtils.toString(javaType)} as ${AstPrinter.printAst(graphQLType)}: ${result.errors.map { it.message }} ${result.getData()}" + val value = value(shape, leaf(graphQLType)) + val result = GraphQL.newGraphQL(fieldSchema(graphQLType) { environment -> unwrapper.unwrap(value) { environment } }).build() + .execute(if (leaf(graphQLType) is Payload) "query { field { data } }" else "query { field }") + "${TypeUtils.toString(potentialMatch.javaType)} as ${AstPrinter.printAst(graphQLType)}: ${result.errors.map { it.message }} ${result.getData()}" .takeUnless { result.errors.isEmpty() && result.getData() == mapOf("field" to expected(graphQLType)) } } - assertTrue(accepted.size > graphQLTypes.size) + assertTrue(accepted.count { (_, potentialMatch) -> leaf(potentialMatch.graphQLType) is Leaf } > graphQLTypes.size) assertEquals(failures, listOf()) } - private fun javaType(shape: List>, wildcardElements: Boolean): java.lang.reflect.Type = - shape.foldRight(Payload::class.java as java.lang.reflect.Type) { container, inner -> + private fun leaf(graphQLType: Type<*>): Any = if (graphQLType.unwrap().name == "Leaf") Leaf("a") else Payload("a") + + private fun javaType(shape: List>, leafClass: Class<*>, wildcardElements: Boolean): java.lang.reflect.Type = + shape.foldRight(leafClass as java.lang.reflect.Type) { container, inner -> val element = if (wildcardElements && container == List::class.java) TypeUtils.wildcardType().withUpperBounds(inner).build() else inner ParameterizedTypeImpl(container, arrayOf(element), null) } - private fun value(shape: List>): Any = shape.foldRight(Payload("a") as Any) { container, inner -> + private fun value(shape: List>, leaf: Any): Any = shape.foldRight(leaf) { container, inner -> when (container) { List::class.java -> listOf(inner, inner) CompletableFuture::class.java -> CompletableFuture.supplyAsync { inner } @@ -225,7 +233,7 @@ class NestedGenericWrapperTest { private fun expected(graphQLType: Type<*>): Any = when (graphQLType) { is ListType -> List(2) { expected(graphQLType.type) } is NonNullType -> expected(graphQLType.type) - else -> mapOf("data" to "a") + else -> if (leaf(graphQLType) is Payload) mapOf("data" to "a") else "a" } private fun fieldSchema(graphQLType: Type<*>, dataFetcher: DataFetcher<*>): GraphQLSchema = GraphQLSchema.newSchema() @@ -236,7 +244,7 @@ class NestedGenericWrapperTest { private fun outputType(graphQLType: Type<*>): GraphQLOutputType = when (graphQLType) { is ListType -> GraphQLList.list(outputType(graphQLType.type)) is NonNullType -> GraphQLNonNull.nonNull(outputType(graphQLType.type)) - else -> payloadType + else -> if (leaf(graphQLType) is Payload) payloadType else leafType } private fun schema(query: Query, vararg scalars: GraphQLScalarType): GraphQLSchema = SchemaParser.newParser() @@ -272,7 +280,7 @@ class NestedGenericWrapperTest { plainList: [Payload!]! futureOfList: [Payload!]! wrappedFuture: Payload! - ${if (scalars.isEmpty()) "" else "json: [JSON] futureJson: JSON"} + ${if (scalars.isEmpty()) "" else "json: [JSON] futureJson: JSON jsonFutures: [JSON]"} } type Payload { @@ -332,6 +340,9 @@ class NestedGenericWrapperTest { data class Payload(val data: String) + // a Future, so a custom scalar's value that only happens to be one is shown to reach the scalar + data class Leaf(val data: String) : Future by completedFuture(data) + class Query : GraphQLQueryResolver { val plainList = listOf(Payload("a")) val futureOfList: CompletableFuture> = completedFuture(plainList) @@ -372,6 +383,7 @@ class NestedGenericWrapperTest { fun listOfPlainFutures(): List> = listOf(FutureTask { Payload("a") }.apply { run() }) fun json(): List>> = listOf(Try.success(mapOf("a" to 1)), Try.success(mapOf("b" to 2))) fun futureJson(): CompletableFuture>> = completedFuture(Try.success(mapOf("c" to 3))) + fun jsonFutures(): List>> = listOf(completedFuture(mapOf("d" to 4)), CompletableFuture.supplyAsync { mapOf("e" to 5) }) fun batchOfPayloads(): Batch = Batch(listOfFutures()) } @@ -387,5 +399,12 @@ class NestedGenericWrapperTest { .name("Payload") .field(GraphQLFieldDefinition.newFieldDefinition().name("data").type(GraphQLNonNull.nonNull(Scalars.GraphQLString))) .build() + private val leafType = GraphQLScalarType.newScalar() + .name("Leaf") + .coercing(object : Coercing { + override fun serialize(dataFetcherResult: Any, graphQLContext: GraphQLContext, locale: Locale): String = + (dataFetcherResult as? Leaf)?.data ?: throw CoercingSerializeException("Expected a Leaf but got $dataFetcherResult") + }) + .build() } } From 90b1049edbab77e02b26ff5ff94269a762e90073 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 16:20:24 -0400 Subject: [PATCH 03/10] Give every wrapper the schema build matched its own step A future directly inside another wrapper, as in Try>, got no step, because the wrapper's step waited on the future before the next step ran. Every wrapper in the record now gets a step, and a wrapper hands a future to that future's step as is, so each step sees the value at its own position. A future's step waits on it. graphql-java still gets the exact future the resolver returned for Try>, since that future's step has nothing below it. --- .../tools/resolver/ReturnValueUnwrapper.kt | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index d04fef35..31de080a 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -17,6 +17,7 @@ internal class ReturnValueUnwrapper private constructor( private val genericWrappers: List, private val method: Method, private val valueClass: Class<*>?, + private val isOutermostFuture: Boolean, private val nested: Step? ) { @@ -30,16 +31,18 @@ internal class ReturnValueUnwrapper private constructor( return if (nested != null && unwrapped is CompletionStage<*>) unwrapped.asDeferred().await() else unwrapped } - private fun unwrap(value: Any, environmentSupplier: Supplier, isSuspendFunction: Boolean): Any? = - value.transformWithGenericWrapper(genericWrappers, environmentSupplier) - .requireAwaitable(valueClass, method, isSuspendFunction) - .then(nested, environmentSupplier) + private fun unwrap(value: Any, environmentSupplier: Supplier, isSuspendFunction: Boolean): Any? { + val unwrapped = value.transformWithGenericWrapper(genericWrappers, environmentSupplier).requireAwaitable(valueClass, method, isSuspendFunction) + return if (isOutermostFuture) unwrapped.then(nested, environmentSupplier) else unwrapped.handOver(nested, environmentSupplier) + } companion object { fun create(method: Method, unwrapped: List, valueClass: Class<*>?, isSubscription: Boolean, genericWrappers: List): ReturnValueUnwrapper { + // picked by the value's class, like a resolver declared as Object returning a wrapper + val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper // graphql-java completes a subscription's events one at a time - val nested = if (isSubscription) null else nestedSteps(unwrapped, genericWrappers) - return ReturnValueUnwrapper(genericWrappers, method, valueClass, nested) + val nested = if (isSubscription) null else steps(if (outermost == null) unwrapped else unwrapped.drop(1), genericWrappers) + return ReturnValueUnwrapper(genericWrappers, method, valueClass, outermost?.isFuture == true, nested) } } } @@ -48,11 +51,16 @@ private sealed interface Step { fun unwrap(value: Any?, environmentSupplier: Supplier): Any? } -private class Peel(private val genericWrappers: List, private val next: Step?) : Step { +private class Await(private val genericWrappers: List, private val next: Step?) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = value?.transformWithGenericWrapper(genericWrappers, environmentSupplier).then(next, environmentSupplier) } +private class Peel(private val genericWrappers: List, private val next: Step?) : Step { + override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = + value?.transformWithGenericWrapper(genericWrappers, environmentSupplier).handOver(next, environmentSupplier) +} + private class EachElement(private val element: Step) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = if (value is Iterable<*>) value.map { element.unwrap(it, environmentSupplier) }.awaitAll() else value @@ -73,24 +81,23 @@ private class ValueOf(private val present: Step) : Step { if (value is Optional<*>) present.unwrap(value.orElse(null), environmentSupplier) else value } -private val Unwrapped.isFuture - get() = this is Unwrapped.Wrapper && (Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type)) - -private fun nestedSteps(unwrapped: List, genericWrappers: List): Step? { - fun isFutureReturnedByWrapperBefore(i: Int) = - unwrapped[i].isFuture && unwrapped.getOrNull(i - 1).let { it is Unwrapped.Wrapper && !it.isFuture } +private val Unwrapped.Wrapper.isFuture + get() = Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type) - val awaited = unwrapped.filterIndexed { i, _ -> !isFutureReturnedByWrapperBefore(i) } - val belowOutermost = if (awaited.firstOrNull() is Unwrapped.Wrapper) awaited.drop(1) else awaited - return belowOutermost.foldRight(null as Step?) { it, next -> +private fun steps(unwrapped: List, genericWrappers: List): Step? = + unwrapped.foldRight(null as Step?) { it, next -> when (it) { - is Unwrapped.Wrapper -> Peel(genericWrappers, next) + is Unwrapped.Wrapper -> if (it.isFuture) Await(genericWrappers, next) else Peel(genericWrappers, next) Unwrapped.Data -> next?.let(::DataOf) Unwrapped.Present -> next?.let(::ValueOf) Unwrapped.Elements -> next?.let(::EachElement) } } -} + +// a future goes to its own step as is, which waits on it. Any other CompletionStage, such as a DataLoader load that a +// transformer returns, is waited on before the next step +private fun Any?.handOver(next: Step?, environmentSupplier: Supplier): Any? = + if (next is Await) next.unwrap(this, environmentSupplier) else then(next, environmentSupplier) private fun Any?.then(next: Step?, environmentSupplier: Supplier): Any? = if (next == null) this else then { next.unwrap(it, environmentSupplier) } From 15e92ef3ca13464b7c6af48ce9ef4aadfc49736a Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 16:21:18 -0400 Subject: [PATCH 04/10] Reject nested futures graphql-java can't wait on A Future that isn't a CompletionStage inside a list or another wrapper, as in List> holding a FutureTask, reached graphql-java as is and failed with "Expected source object to be an instance of 'Item'", unless the app's ValueUnboxer called get() on it. It now fails with the error the outermost value already gives, which says to return a CompletionStage. --- .../tools/resolver/ReturnValueUnwrapper.kt | 25 +++++++++++------- .../tools/NestedGenericWrapperTest.kt | 26 ++++++++++--------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index 31de080a..b9824fdb 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -41,7 +41,7 @@ internal class ReturnValueUnwrapper private constructor( // picked by the value's class, like a resolver declared as Object returning a wrapper val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper // graphql-java completes a subscription's events one at a time - val nested = if (isSubscription) null else steps(if (outermost == null) unwrapped else unwrapped.drop(1), genericWrappers) + val nested = if (isSubscription) null else steps(if (outermost == null) unwrapped else unwrapped.drop(1), genericWrappers, method) return ReturnValueUnwrapper(genericWrappers, method, valueClass, outermost?.isFuture == true, nested) } } @@ -51,9 +51,9 @@ private sealed interface Step { fun unwrap(value: Any?, environmentSupplier: Supplier): Any? } -private class Await(private val genericWrappers: List, private val next: Step?) : Step { +private class Await(private val genericWrappers: List, private val method: Method, private val next: Step?) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = - value?.transformWithGenericWrapper(genericWrappers, environmentSupplier).then(next, environmentSupplier) + value?.transformWithGenericWrapper(genericWrappers, environmentSupplier).requireCompletionStage(method).then(next, environmentSupplier) } private class Peel(private val genericWrappers: List, private val next: Step?) : Step { @@ -84,10 +84,10 @@ private class ValueOf(private val present: Step) : Step { private val Unwrapped.Wrapper.isFuture get() = Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type) -private fun steps(unwrapped: List, genericWrappers: List): Step? = +private fun steps(unwrapped: List, genericWrappers: List, method: Method): Step? = unwrapped.foldRight(null as Step?) { it, next -> when (it) { - is Unwrapped.Wrapper -> if (it.isFuture) Await(genericWrappers, next) else Peel(genericWrappers, next) + is Unwrapped.Wrapper -> if (it.isFuture) Await(genericWrappers, method, next) else Peel(genericWrappers, next) Unwrapped.Data -> next?.let(::DataOf) Unwrapped.Present -> next?.let(::ValueOf) Unwrapped.Elements -> next?.let(::EachElement) @@ -144,13 +144,17 @@ private fun Any?.requireAwaitable(valueClass: Class<*>?, method: Method, isSuspe 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.") + throw ResolverError("Suspend function '${method.resolverName}' resolved to a ${javaClass.name}, which graphql-java can't wait on. Return its value instead.") } - if (this !is CompletionStage<*>) { + + return requireCompletionStage(method) +} + +private fun Any?.requireCompletionStage(method: Method): Any? { + if (this is Future<*> && this !is CompletionStage<*>) { throw ResolverError( - "Resolver method '$resolverName' resolved to a ${javaClass.name}, which graphql-java can't wait on. " + + "Resolver method '${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." ) } @@ -158,6 +162,9 @@ private fun Any?.requireAwaitable(valueClass: Class<*>?, method: Method, isSuspe return this } +private val Method.resolverName + get() = "${declaringClass.name}.$name" + private class CompareGenericWrappers { companion object : Comparator { override fun compare(w1: GenericWrapper, w2: GenericWrapper): Int = when { diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index 6dbd711b..d878ad00 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -130,14 +130,11 @@ class NestedGenericWrapperTest { fun `fails the list when one of its wrappers in a future fails`() = assertFails("futureOfListWithFailedWrapper", "failed wrapper") @Test - fun `leaves futures in a list that graphql-java can't wait on as they are`() { - val result = execute("listOfPlainFutures") - - assertEquals(result.errors.map { it.message }, listOf( - "Exception while fetching data (/listOfPlainFutures[0]/data) : " + - "Expected source object to be an instance of '${Payload::class.java.name}' but instead got '${FutureTask::class.java.name}'" - )) - } + fun `fails a list of futures graphql-java can't wait on`() = assertFails( + "listOfPlainFutures", + "Resolver method '${Query::class.java.name}.listOfPlainFutures' 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." + ) @Test fun `unwraps the wrappers a schemaWrapper introduces`() = assertResolves("batchOfPayloads", ab) @@ -181,7 +178,7 @@ class NestedGenericWrapperTest { val matcher = TypeClassMatcher(mapOf("Payload" to ObjectTypeDefinition("Payload"), "Leaf" to ScalarTypeDefinition("Leaf"))) val method = Query::class.java.getMethod("listOfWrapped") val graphQLTypes = listOf(TypeName("Payload"), TypeName("Leaf")).flatMap { listOf(it, NonNullType(it), ListType(it), NonNullType(ListType(NonNullType(it))), ListType(ListType(it))) } - val containers = listOf(List::class.java, CompletableFuture::class.java, Try::class.java, Page::class.java, DataFetcherResult::class.java, Optional::class.java) + val containers = listOf(List::class.java, CompletableFuture::class.java, Try::class.java, Page::class.java, DataFetcherResult::class.java, Optional::class.java, Future::class.java) val shapes = (1..4).fold(listOf(listOf>())) { shapes, _ -> shapes + shapes.filter { it.size == shapes.last().size }.flatMap { shape -> containers.map { shape + it } } } val accepted = graphQLTypes.flatMap { graphQLType -> @@ -203,8 +200,12 @@ class NestedGenericWrapperTest { val value = value(shape, leaf(graphQLType)) val result = GraphQL.newGraphQL(fieldSchema(graphQLType) { environment -> unwrapper.unwrap(value) { environment } }).build() .execute(if (leaf(graphQLType) is Payload) "query { field { data } }" else "query { field }") - "${TypeUtils.toString(potentialMatch.javaType)} as ${AstPrinter.printAst(graphQLType)}: ${result.errors.map { it.message }} ${result.getData()}" - .takeUnless { result.errors.isEmpty() && result.getData() == mapOf("field" to expected(graphQLType)) } + val resolved = if (Future::class.java in shape) { + result.errors.singleOrNull()?.message.orEmpty().contains("which graphql-java can't wait on") + } else { + result.errors.isEmpty() && result.getData() == mapOf("field" to expected(graphQLType)) + } + "${TypeUtils.toString(potentialMatch.javaType)} as ${AstPrinter.printAst(graphQLType)}: ${result.errors.map { it.message }} ${result.getData()}".takeUnless { resolved } } assertTrue(accepted.count { (_, potentialMatch) -> leaf(potentialMatch.graphQLType) is Leaf } > graphQLTypes.size) @@ -226,7 +227,8 @@ class NestedGenericWrapperTest { Try::class.java -> Try.success(inner) Page::class.java -> Page(listOf(inner, inner)) DataFetcherResult::class.java -> DataFetcherResult.newResult().data(inner).build() - else -> Optional.of(inner) + Optional::class.java -> Optional.of(inner) + else -> FutureTask { inner }.apply { run() } } } From a8ce63066c15af48ed72385f9a5f6c0d3e732d54 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 16:22:45 -0400 Subject: [PATCH 05/10] Unwrap a nested wrapper with the wrapper its declared type matched A nested wrapper was unwrapped with the most specific wrapper its value is an instance of, the same search as for the outermost value. Each step now calls the transformer of the wrapper the schema build matched at that position, so a subclass registered as a wrapper of its own no longer takes over below the outermost value, and nested values skip the search. The outermost value is still picked by its class, since a resolver can be declared as Object and return a wrapper. --- .../tools/resolver/ReturnValueUnwrapper.kt | 14 +++++++------- .../kickstart/tools/NestedGenericWrapperTest.kt | 13 ++++++++++++- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index b9824fdb..9dfa0d1a 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -41,7 +41,7 @@ internal class ReturnValueUnwrapper private constructor( // picked by the value's class, like a resolver declared as Object returning a wrapper val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper // graphql-java completes a subscription's events one at a time - val nested = if (isSubscription) null else steps(if (outermost == null) unwrapped else unwrapped.drop(1), genericWrappers, method) + val nested = if (isSubscription) null else steps(if (outermost == null) unwrapped else unwrapped.drop(1), method) return ReturnValueUnwrapper(genericWrappers, method, valueClass, outermost?.isFuture == true, nested) } } @@ -51,14 +51,14 @@ private sealed interface Step { fun unwrap(value: Any?, environmentSupplier: Supplier): Any? } -private class Await(private val genericWrappers: List, private val method: Method, private val next: Step?) : Step { +private class Await(private val wrapper: GenericWrapper, private val method: Method, private val next: Step?) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = - value?.transformWithGenericWrapper(genericWrappers, environmentSupplier).requireCompletionStage(method).then(next, environmentSupplier) + value?.let { wrapper.transformer(it, environmentSupplier.get()) }.requireCompletionStage(method).then(next, environmentSupplier) } -private class Peel(private val genericWrappers: List, private val next: Step?) : Step { +private class Peel(private val wrapper: GenericWrapper, private val next: Step?) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = - value?.transformWithGenericWrapper(genericWrappers, environmentSupplier).handOver(next, environmentSupplier) + value?.let { wrapper.transformer(it, environmentSupplier.get()) }.handOver(next, environmentSupplier) } private class EachElement(private val element: Step) : Step { @@ -84,10 +84,10 @@ private class ValueOf(private val present: Step) : Step { private val Unwrapped.Wrapper.isFuture get() = Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type) -private fun steps(unwrapped: List, genericWrappers: List, method: Method): Step? = +private fun steps(unwrapped: List, method: Method): Step? = unwrapped.foldRight(null as Step?) { it, next -> when (it) { - is Unwrapped.Wrapper -> if (it.isFuture) Await(genericWrappers, method, next) else Peel(genericWrappers, next) + is Unwrapped.Wrapper -> if (it.isFuture) Await(it.wrapper, method, next) else Peel(it.wrapper, next) Unwrapped.Data -> next?.let(::DataOf) Unwrapped.Present -> next?.let(::ValueOf) Unwrapped.Elements -> next?.let(::EachElement) diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index d878ad00..a8dfc6d6 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -139,6 +139,9 @@ class NestedGenericWrapperTest { @Test fun `unwraps the wrappers a schemaWrapper introduces`() = assertResolves("batchOfPayloads", ab) + @Test + fun `picks a nested wrapper by the type the schema was matched to`() = assertResolves("listOfBoxes", ab) + @Test fun `gives a custom scalar the unwrapped values`() { val json = mutableListOf() @@ -279,6 +282,7 @@ class NestedGenericWrapperTest { futureOfListWithFailedWrapper: [Payload] listOfPlainFutures: [Payload] batchOfPayloads: [Payload!]! + listOfBoxes: [Payload!]! plainList: [Payload!]! futureOfList: [Payload!]! wrappedFuture: Payload! @@ -293,7 +297,7 @@ class NestedGenericWrapperTest { """) .resolvers(query) .scalars(*scalars) - .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper, batchWrapper).build()) + .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper, batchWrapper, boxWrapper, specialBoxWrapper).build()) .build() .makeExecutableSchema() @@ -387,16 +391,23 @@ class NestedGenericWrapperTest { fun futureJson(): CompletableFuture>> = completedFuture(Try.success(mapOf("c" to 3))) fun jsonFutures(): List>> = listOf(completedFuture(mapOf("d" to 4)), CompletableFuture.supplyAsync { mapOf("e" to 5) }) fun batchOfPayloads(): Batch = Batch(listOfFutures()) + fun listOfBoxes(): List> = listOf(Box(Payload("a")), SpecialBox(Payload("b"))) } class Batch(val items: List>) + open class Box(val content: T) + + class SpecialBox(content: T) : Box(content) + companion object { private val tryWrapper = GenericWrapper.withTransformer(Try::class, 0, { wrapper -> wrapper.get() }) private val pageWrapper = GenericWrapper.listCollectionWithTransformer(Page::class, 0, { page -> page.content }) private val batchWrapper = GenericWrapper.withTransformer(Batch::class, 0, { batch -> batch.items }) { item -> ParameterizedTypeImpl(List::class.java, arrayOf(ParameterizedTypeImpl(CompletableFuture::class.java, arrayOf(item), null)), null) } + private val boxWrapper = GenericWrapper.withTransformer(Box::class, 0, { box -> box.content }) + private val specialBoxWrapper = GenericWrapper.withTransformer(SpecialBox::class, 0, { _ -> throw IllegalStateException("picked by the value's class") }) private val payloadType = GraphQLObjectType.newObject() .name("Payload") .field(GraphQLFieldDefinition.newFieldDefinition().name("data").type(GraphQLNonNull.nonNull(Scalars.GraphQLString))) From 76ba3a9b878a09c1f2bc4890bf81044ee64b512b Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 16:27:28 -0400 Subject: [PATCH 06/10] Let names carry what the new comments said --- .../graphql/kickstart/tools/TypeClassMatcher.kt | 6 ------ .../kickstart/tools/resolver/MethodFieldResolver.kt | 1 - .../kickstart/tools/resolver/ReturnValueUnwrapper.kt | 9 +++------ .../kickstart/tools/NestedGenericWrapperTest.kt | 11 +++++------ 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt b/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt index ec2212fd..44f7c64a 100644 --- a/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt +++ b/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt @@ -53,9 +53,6 @@ internal class TypeClassMatcher(private val definitionsByName: Map, val graphQLType: GraphQLLangType, val javaType: JavaType) internal sealed interface Unwrapped { diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index 6b079bd5..3ef27977 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -180,7 +180,6 @@ internal class MethodFieldResolver( typeResolver.resolveTypeVariables(declaredType) } - // the declared type, so the match also covers the wrappers of the value the method returns private val returnValueMatch by lazy { TypeClassMatcher.PotentialMatch.returnValue(field.type, matchedReturnType(), typeResolver, ScanDictionary.ReturnValueReference(method)) } diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index 9dfa0d1a..df8ffc04 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -33,12 +33,11 @@ internal class ReturnValueUnwrapper private constructor( private fun unwrap(value: Any, environmentSupplier: Supplier, isSuspendFunction: Boolean): Any? { val unwrapped = value.transformWithGenericWrapper(genericWrappers, environmentSupplier).requireAwaitable(valueClass, method, isSuspendFunction) - return if (isOutermostFuture) unwrapped.then(nested, environmentSupplier) else unwrapped.handOver(nested, environmentSupplier) + return if (isOutermostFuture) unwrapped.then(nested, environmentSupplier) else unwrapped.thenUnlessNextAwaits(nested, environmentSupplier) } companion object { fun create(method: Method, unwrapped: List, valueClass: Class<*>?, isSubscription: Boolean, genericWrappers: List): ReturnValueUnwrapper { - // picked by the value's class, like a resolver declared as Object returning a wrapper val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper // graphql-java completes a subscription's events one at a time val nested = if (isSubscription) null else steps(if (outermost == null) unwrapped else unwrapped.drop(1), method) @@ -58,7 +57,7 @@ private class Await(private val wrapper: GenericWrapper, private val method: Met private class Peel(private val wrapper: GenericWrapper, private val next: Step?) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = - value?.let { wrapper.transformer(it, environmentSupplier.get()) }.handOver(next, environmentSupplier) + value?.let { wrapper.transformer(it, environmentSupplier.get()) }.thenUnlessNextAwaits(next, environmentSupplier) } private class EachElement(private val element: Step) : Step { @@ -94,9 +93,7 @@ private fun steps(unwrapped: List, method: Method): Step? = } } -// a future goes to its own step as is, which waits on it. Any other CompletionStage, such as a DataLoader load that a -// transformer returns, is waited on before the next step -private fun Any?.handOver(next: Step?, environmentSupplier: Supplier): Any? = +private fun Any?.thenUnlessNextAwaits(next: Step?, environmentSupplier: Supplier): Any? = if (next is Await) next.unwrap(this, environmentSupplier) else then(next, environmentSupplier) private fun Any?.then(next: Step?, environmentSupplier: Supplier): Any? = diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index a8dfc6d6..98bb2624 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -211,11 +211,11 @@ class NestedGenericWrapperTest { "${TypeUtils.toString(potentialMatch.javaType)} as ${AstPrinter.printAst(graphQLType)}: ${result.errors.map { it.message }} ${result.getData()}".takeUnless { resolved } } - assertTrue(accepted.count { (_, potentialMatch) -> leaf(potentialMatch.graphQLType) is Leaf } > graphQLTypes.size) + assertTrue(accepted.count { (_, potentialMatch) -> leaf(potentialMatch.graphQLType) is FutureLeaf } > graphQLTypes.size) assertEquals(failures, listOf()) } - private fun leaf(graphQLType: Type<*>): Any = if (graphQLType.unwrap().name == "Leaf") Leaf("a") else Payload("a") + private fun leaf(graphQLType: Type<*>): Any = if (graphQLType.unwrap().name == "Leaf") FutureLeaf("a") else Payload("a") private fun javaType(shape: List>, leafClass: Class<*>, wildcardElements: Boolean): java.lang.reflect.Type = shape.foldRight(leafClass as java.lang.reflect.Type) { container, inner -> @@ -346,8 +346,7 @@ class NestedGenericWrapperTest { data class Payload(val data: String) - // a Future, so a custom scalar's value that only happens to be one is shown to reach the scalar - data class Leaf(val data: String) : Future by completedFuture(data) + data class FutureLeaf(val data: String) : Future by completedFuture(data) class Query : GraphQLQueryResolver { val plainList = listOf(Payload("a")) @@ -414,9 +413,9 @@ class NestedGenericWrapperTest { .build() private val leafType = GraphQLScalarType.newScalar() .name("Leaf") - .coercing(object : Coercing { + .coercing(object : Coercing { override fun serialize(dataFetcherResult: Any, graphQLContext: GraphQLContext, locale: Locale): String = - (dataFetcherResult as? Leaf)?.data ?: throw CoercingSerializeException("Expected a Leaf but got $dataFetcherResult") + (dataFetcherResult as? FutureLeaf)?.data ?: throw CoercingSerializeException("Expected a FutureLeaf but got $dataFetcherResult") }) .build() } From bf6a9ba97b4ad4339042f50d83c44d64126f2bbf Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 17:00:30 -0400 Subject: [PATCH 07/10] Give a nested future graphql-java can't wait on the outermost value's error --- .../tools/resolver/MethodFieldResolver.kt | 2 +- .../tools/resolver/ReturnValueUnwrapper.kt | 77 ++++++++++--------- .../tools/NestedGenericWrapperTest.kt | 36 +++++++-- 3 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index 3ef27977..9050738c 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -130,7 +130,7 @@ internal class MethodFieldResolver( // Object (e.g. for a union) can't tell them apart, so then every future of a method whose return type is Future is checked val valueClass = (TypeUtils.getRawType(getResolvedReturnType(), null) ?: Any::class.java) .takeUnless { it == Any::class.java && TypeUtils.getRawType(returnType, null) == Future::class.java } - val unwrapper = ReturnValueUnwrapper.create(method, returnValueMatch.unwrapping.unwrapped, valueClass, search.isSubscription, options.genericWrappers) + val unwrapper = ReturnValueUnwrapper.create(method, returnValueMatch.unwrapping, valueClass, isSuspendFunction, search.isSubscription, options.genericWrappers) return if (numberOfParameters > 0 || isSuspendFunction) { // requires arguments and environment or is a suspend function diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index df8ffc04..19a878d0 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -4,8 +4,10 @@ import graphql.execution.DataFetcherResult import graphql.kickstart.tools.ResolverError import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper import graphql.kickstart.tools.TypeClassMatcher.Unwrapped +import graphql.kickstart.tools.TypeClassMatcher.Unwrapping import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.future.asDeferred +import org.apache.commons.lang3.reflect.TypeUtils import java.lang.reflect.Method import java.util.* import java.util.concurrent.CompletableFuture @@ -15,33 +17,39 @@ import java.util.function.Supplier internal class ReturnValueUnwrapper private constructor( private val genericWrappers: List, - private val method: Method, - private val valueClass: Class<*>?, + private val awaitable: Awaitable, private val isOutermostFuture: Boolean, private val nested: Step? ) { - fun unwrap(value: Any, environmentSupplier: Supplier): Any? = - unwrap(value, environmentSupplier, isSuspendFunction = false) + fun unwrap(value: Any, environmentSupplier: Supplier): Any? { + val unwrapped = awaitable.requireOutermost(value.transformWithGenericWrapper(genericWrappers, environmentSupplier)) + return if (isOutermostFuture) unwrapped.then(nested, environmentSupplier) else unwrapped.thenUnlessNextAwaits(nested, environmentSupplier) + } suspend fun unwrapSuspending(value: Any, environmentSupplier: Supplier): Any? { - val unwrapped = unwrap(value, environmentSupplier, isSuspendFunction = true) + val unwrapped = unwrap(value, environmentSupplier) // the coroutine's future would complete with the future made below the outermost value, which graphql-java // doesn't wait on. asDeferred, so cancelling the coroutine doesn't cancel a future the resolver returned return if (nested != null && unwrapped is CompletionStage<*>) unwrapped.asDeferred().await() else unwrapped } - private fun unwrap(value: Any, environmentSupplier: Supplier, isSuspendFunction: Boolean): Any? { - val unwrapped = value.transformWithGenericWrapper(genericWrappers, environmentSupplier).requireAwaitable(valueClass, method, isSuspendFunction) - return if (isOutermostFuture) unwrapped.then(nested, environmentSupplier) else unwrapped.thenUnlessNextAwaits(nested, environmentSupplier) - } - companion object { - fun create(method: Method, unwrapped: List, valueClass: Class<*>?, isSubscription: Boolean, genericWrappers: List): ReturnValueUnwrapper { + fun create( + method: Method, + unwrapping: Unwrapping, + valueClass: Class<*>?, + isSuspendFunction: Boolean, + isSubscription: Boolean, + genericWrappers: List + ): ReturnValueUnwrapper { + val unwrapped = unwrapping.unwrapped val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper + val belowOutermost = if (outermost == null) unwrapped else unwrapped.drop(1) + val leafClass = TypeUtils.getRawType(unwrapping.javaType, null) // graphql-java completes a subscription's events one at a time - val nested = if (isSubscription) null else steps(if (outermost == null) unwrapped else unwrapped.drop(1), method) - return ReturnValueUnwrapper(genericWrappers, method, valueClass, outermost?.isFuture == true, nested) + val nested = if (isSubscription) null else steps(belowOutermost, Awaitable(method, leafClass, isSuspendFunction)) + return ReturnValueUnwrapper(genericWrappers, Awaitable(method, valueClass, isSuspendFunction), outermost?.isFuture == true, nested) } } } @@ -50,14 +58,16 @@ private sealed interface Step { fun unwrap(value: Any?, environmentSupplier: Supplier): Any? } -private class Await(private val wrapper: GenericWrapper, private val method: Method, private val next: Step?) : Step { +private class Await(private val wrapper: GenericWrapper, private val awaitable: Awaitable, private val next: Step?) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = - value?.let { wrapper.transformer(it, environmentSupplier.get()) }.requireCompletionStage(method).then(next, environmentSupplier) + awaitable.requireNested(value?.let { wrapper.transformer(it, environmentSupplier.get()) }).then(next, environmentSupplier) } -private class Peel(private val wrapper: GenericWrapper, private val next: Step?) : Step { - override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = - value?.let { wrapper.transformer(it, environmentSupplier.get()) }.thenUnlessNextAwaits(next, environmentSupplier) +private class Peel(private val wrapper: GenericWrapper, private val awaitable: Awaitable, private val next: Step?) : Step { + override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? { + val transformed = value?.let { wrapper.transformer(it, environmentSupplier.get()) } + return if (next is Await) next.unwrap(transformed, environmentSupplier) else awaitable.requireNested(transformed).then(next, environmentSupplier) + } } private class EachElement(private val element: Step) : Step { @@ -83,10 +93,10 @@ private class ValueOf(private val present: Step) : Step { private val Unwrapped.Wrapper.isFuture get() = Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type) -private fun steps(unwrapped: List, method: Method): Step? = +private fun steps(unwrapped: List, awaitable: Awaitable): Step? = unwrapped.foldRight(null as Step?) { it, next -> when (it) { - is Unwrapped.Wrapper -> if (it.isFuture) Await(it.wrapper, method, next) else Peel(it.wrapper, next) + is Unwrapped.Wrapper -> if (it.isFuture) Await(it.wrapper, awaitable, next) else Peel(it.wrapper, awaitable, next) Unwrapped.Data -> next?.let(::DataOf) Unwrapped.Present -> next?.let(::ValueOf) Unwrapped.Elements -> next?.let(::EachElement) @@ -136,27 +146,24 @@ 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) == true) { - return this - } - - if (isSuspendFunction) { - throw ResolverError("Suspend function '${method.resolverName}' resolved to a ${javaClass.name}, which graphql-java can't wait on. Return its value instead.") - } +private class Awaitable(private val method: Method, private val valueClass: Class<*>?, private val isSuspendFunction: Boolean) { - return requireCompletionStage(method) -} + fun requireOutermost(value: Any?): Any? { + if (value !is Future<*> || valueClass?.isInstance(value) == true || value is CompletionStage<*> && !isSuspendFunction) { + return value + } -private fun Any?.requireCompletionStage(method: Method): Any? { - if (this is Future<*> && this !is CompletionStage<*>) { throw ResolverError( - "Resolver method '${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." + if (isSuspendFunction) { + "Suspend function '${method.resolverName}' resolved to a ${value.javaClass.name}, which graphql-java can't wait on. Return its value instead." + } else { + "Resolver method '${method.resolverName}' resolved to a ${value.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 + fun requireNested(value: Any?): Any? = if (value is CompletionStage<*>) value else requireOutermost(value) } private val Method.resolverName diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index 98bb2624..af31e05d 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -130,12 +130,20 @@ class NestedGenericWrapperTest { fun `fails the list when one of its wrappers in a future fails`() = assertFails("futureOfListWithFailedWrapper", "failed wrapper") @Test - fun `fails a list of futures graphql-java can't wait on`() = assertFails( - "listOfPlainFutures", - "Resolver method '${Query::class.java.name}.listOfPlainFutures' 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." + fun `fails a list of futures graphql-java can't wait on`() = assertFails("listOfPlainFutures", cantWaitOn("listOfPlainFutures")) + + @Test + fun `fails a list of wrappers whose transformer returns a future graphql-java can't wait on`() = assertFails("listOfLater", cantWaitOn("listOfLater")) + + @Test + fun `fails a list of futures graphql-java can't wait on from a suspend function`() = assertFails( + "suspendListOfPlainFutures", + "Suspend function '${Query::class.java.name}.suspendListOfPlainFutures' resolved to a ${FutureTask::class.java.name}, which graphql-java can't wait on. Return its value instead." ) + @Test + fun `hands a future graphql-java can't wait on to its own wrapper`() = assertResolves("listOfWrappedPending", ab) + @Test fun `unwraps the wrappers a schemaWrapper introduces`() = assertResolves("batchOfPayloads", ab) @@ -198,7 +206,7 @@ class NestedGenericWrapperTest { val failures = accepted.mapNotNull { (shape, potentialMatch) -> val graphQLType = potentialMatch.graphQLType val unwrapper = ReturnValueUnwrapper.create( - method, potentialMatch.unwrapping.unwrapped, TypeUtils.getRawType(typeResolver.resolve(potentialMatch.javaType), null), false, options.genericWrappers + method, potentialMatch.unwrapping, TypeUtils.getRawType(typeResolver.resolve(potentialMatch.javaType), null), false, false, options.genericWrappers ) val value = value(shape, leaf(graphQLType)) val result = GraphQL.newGraphQL(fieldSchema(graphQLType) { environment -> unwrapper.unwrap(value) { environment } }).build() @@ -281,6 +289,9 @@ class NestedGenericWrapperTest { listWithFailedWrapper: [Payload] futureOfListWithFailedWrapper: [Payload] listOfPlainFutures: [Payload] + listOfLater: [Payload] + suspendListOfPlainFutures: [Payload] + listOfWrappedPending: [Payload!]! batchOfPayloads: [Payload!]! listOfBoxes: [Payload!]! plainList: [Payload!]! @@ -297,7 +308,7 @@ class NestedGenericWrapperTest { """) .resolvers(query) .scalars(*scalars) - .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper, batchWrapper, boxWrapper, specialBoxWrapper).build()) + .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper, batchWrapper, boxWrapper, specialBoxWrapper, laterWrapper, pendingWrapper).build()) .build() .makeExecutableSchema() @@ -314,6 +325,10 @@ class NestedGenericWrapperTest { assertEquals(result.getData(), mapOf(field to expected)) } + private fun cantWaitOn(field: String) = + "Resolver method '${Query::class.java.name}.$field' 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 assertFails(field: String, message: String) { val result = execute(field) assertEquals(result.errors.map { it.message }, listOf("Exception while fetching data (/$field) : $message")) @@ -386,6 +401,9 @@ class NestedGenericWrapperTest { fun listWithFailedWrapper(): List> = listOf(Try.success(Payload("a")), Try.failure("failed wrapper")) fun futureOfListWithFailedWrapper(): CompletableFuture>> = CompletableFuture.supplyAsync { listWithFailedWrapper() } fun listOfPlainFutures(): List> = listOf(FutureTask { Payload("a") }.apply { run() }) + fun listOfLater(): List> = listOf(Later(Payload("a"))) + suspend fun suspendListOfPlainFutures(): List> = listOfPlainFutures() + fun listOfWrappedPending(): List>> = listOf(Try.success(Pending(Payload("a"))), Try.success(Pending(Payload("b")))) fun json(): List>> = listOf(Try.success(mapOf("a" to 1)), Try.success(mapOf("b" to 2))) fun futureJson(): CompletableFuture>> = completedFuture(Try.success(mapOf("c" to 3))) fun jsonFutures(): List>> = listOf(completedFuture(mapOf("d" to 4)), CompletableFuture.supplyAsync { mapOf("e" to 5) }) @@ -399,6 +417,10 @@ class NestedGenericWrapperTest { class SpecialBox(content: T) : Box(content) + class Later(val value: T) + + class Pending(val value: T) : Future by completedFuture(value) + companion object { private val tryWrapper = GenericWrapper.withTransformer(Try::class, 0, { wrapper -> wrapper.get() }) private val pageWrapper = GenericWrapper.listCollectionWithTransformer(Page::class, 0, { page -> page.content }) @@ -407,6 +429,8 @@ class NestedGenericWrapperTest { } private val boxWrapper = GenericWrapper.withTransformer(Box::class, 0, { box -> box.content }) private val specialBoxWrapper = GenericWrapper.withTransformer(SpecialBox::class, 0, { _ -> throw IllegalStateException("picked by the value's class") }) + private val laterWrapper = GenericWrapper.withTransformer(Later::class, 0, { later -> FutureTask { later.value }.apply { run() } }) + private val pendingWrapper = GenericWrapper.withTransformer(Pending::class, 0, { pending -> completedFuture(pending.value) }) private val payloadType = GraphQLObjectType.newObject() .name("Payload") .field(GraphQLFieldDefinition.newFieldDefinition().name("data").type(GraphQLNonNull.nonNull(Scalars.GraphQLString))) From 013128f526ded5e54b99533362b1c13ec050a377 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 17:03:55 -0400 Subject: [PATCH 08/10] Check every nested future when the leaf is typed as Object --- .../tools/resolver/ReturnValueUnwrapper.kt | 29 +++++++++---------- .../tools/NestedGenericWrapperTest.kt | 14 +++++++-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index 19a878d0..dbec33cc 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -46,7 +46,8 @@ internal class ReturnValueUnwrapper private constructor( val unwrapped = unwrapping.unwrapped val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper val belowOutermost = if (outermost == null) unwrapped else unwrapped.drop(1) - val leafClass = TypeUtils.getRawType(unwrapping.javaType, null) + // Object (e.g. for a union) can't tell a future from a value, so then every nested future is checked + val leafClass = TypeUtils.getRawType(unwrapping.javaType, null)?.takeUnless { it == Any::class.java } // graphql-java completes a subscription's events one at a time val nested = if (isSubscription) null else steps(belowOutermost, Awaitable(method, leafClass, isSuspendFunction)) return ReturnValueUnwrapper(genericWrappers, Awaitable(method, valueClass, isSuspendFunction), outermost?.isFuture == true, nested) @@ -148,22 +149,20 @@ private fun Any.transformWithGenericWrapper( // that a suspend function completes with, would reach it as the field value private class Awaitable(private val method: Method, private val valueClass: Class<*>?, private val isSuspendFunction: Boolean) { - fun requireOutermost(value: Any?): Any? { - if (value !is Future<*> || valueClass?.isInstance(value) == true || value is CompletionStage<*> && !isSuspendFunction) { - return value - } + fun requireOutermost(value: Any?): Any? = + if (value !is Future<*> || valueClass?.isInstance(value) == true || value is CompletionStage<*> && !isSuspendFunction) value else fail(value) - throw ResolverError( - if (isSuspendFunction) { - "Suspend function '${method.resolverName}' resolved to a ${value.javaClass.name}, which graphql-java can't wait on. Return its value instead." - } else { - "Resolver method '${method.resolverName}' resolved to a ${value.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." - } - ) - } + fun requireNested(value: Any?): Any? = + if (value !is Future<*> || value is CompletionStage<*> || valueClass?.isInstance(value) == true) value else fail(value) - fun requireNested(value: Any?): Any? = if (value is CompletionStage<*>) value else requireOutermost(value) + private fun fail(value: Any): Nothing = throw ResolverError( + if (isSuspendFunction) { + "Suspend function '${method.resolverName}' resolved to a ${value.javaClass.name}, which graphql-java can't wait on. Return its value instead." + } else { + "Resolver method '${method.resolverName}' resolved to a ${value.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." + } + ) } private val Method.resolverName diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index af31e05d..947d54a9 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -132,6 +132,10 @@ class NestedGenericWrapperTest { @Test fun `fails a list of futures graphql-java can't wait on`() = assertFails("listOfPlainFutures", cantWaitOn("listOfPlainFutures")) + @Test + fun `fails a list of futures graphql-java can't wait on typed as Object`() = + assertFails("listOfPlainFuturesOfAny", cantWaitOn("listOfPlainFuturesOfAny"), selection = "{ ... on Payload { data } }") + @Test fun `fails a list of wrappers whose transformer returns a future graphql-java can't wait on`() = assertFails("listOfLater", cantWaitOn("listOfLater")) @@ -206,7 +210,7 @@ class NestedGenericWrapperTest { val failures = accepted.mapNotNull { (shape, potentialMatch) -> val graphQLType = potentialMatch.graphQLType val unwrapper = ReturnValueUnwrapper.create( - method, potentialMatch.unwrapping, TypeUtils.getRawType(typeResolver.resolve(potentialMatch.javaType), null), false, false, options.genericWrappers + method, potentialMatch.unwrapping, TypeUtils.getRawType(typeResolver.resolve(potentialMatch.javaType), null), isSuspendFunction = false, isSubscription = false, options.genericWrappers ) val value = value(shape, leaf(graphQLType)) val result = GraphQL.newGraphQL(fieldSchema(graphQLType) { environment -> unwrapper.unwrap(value) { environment } }).build() @@ -289,6 +293,7 @@ class NestedGenericWrapperTest { listWithFailedWrapper: [Payload] futureOfListWithFailedWrapper: [Payload] listOfPlainFutures: [Payload] + listOfPlainFuturesOfAny: [Item] listOfLater: [Payload] suspendListOfPlainFutures: [Payload] listOfWrappedPending: [Payload!]! @@ -304,6 +309,8 @@ class NestedGenericWrapperTest { data: String! } + union Item = Payload + ${if (scalars.isEmpty()) "" else "scalar JSON"} """) .resolvers(query) @@ -329,8 +336,8 @@ class NestedGenericWrapperTest { "Resolver method '${Query::class.java.name}.$field' 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 assertFails(field: String, message: String) { - val result = execute(field) + private fun assertFails(field: String, message: String, selection: String = "{ data }") { + val result = execute(field, selection) assertEquals(result.errors.map { it.message }, listOf("Exception while fetching data (/$field) : $message")) assertEquals(result.getData(), mapOf(field to null)) } @@ -401,6 +408,7 @@ class NestedGenericWrapperTest { fun listWithFailedWrapper(): List> = listOf(Try.success(Payload("a")), Try.failure("failed wrapper")) fun futureOfListWithFailedWrapper(): CompletableFuture>> = CompletableFuture.supplyAsync { listWithFailedWrapper() } fun listOfPlainFutures(): List> = listOf(FutureTask { Payload("a") }.apply { run() }) + fun listOfPlainFuturesOfAny(): List> = listOf(FutureTask { Payload("a") }.apply { run() }) fun listOfLater(): List> = listOf(Later(Payload("a"))) suspend fun suspendListOfPlainFutures(): List> = listOfPlainFutures() fun listOfWrappedPending(): List>> = listOf(Try.success(Pending(Payload("a"))), Try.success(Pending(Payload("b")))) From 860495f911f25a0cb5b7f43773d99ec32c0560eb Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 17:04:22 -0400 Subject: [PATCH 09/10] Let the leaf class's name say it isn't Object --- .../graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index dbec33cc..9699d3bb 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -46,10 +46,9 @@ internal class ReturnValueUnwrapper private constructor( val unwrapped = unwrapping.unwrapped val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper val belowOutermost = if (outermost == null) unwrapped else unwrapped.drop(1) - // Object (e.g. for a union) can't tell a future from a value, so then every nested future is checked - val leafClass = TypeUtils.getRawType(unwrapping.javaType, null)?.takeUnless { it == Any::class.java } + val specificLeafClass = TypeUtils.getRawType(unwrapping.javaType, null)?.takeUnless { it == Any::class.java } // graphql-java completes a subscription's events one at a time - val nested = if (isSubscription) null else steps(belowOutermost, Awaitable(method, leafClass, isSuspendFunction)) + val nested = if (isSubscription) null else steps(belowOutermost, Awaitable(method, specificLeafClass, isSuspendFunction)) return ReturnValueUnwrapper(genericWrappers, Awaitable(method, valueClass, isSuspendFunction), outermost?.isFuture == true, nested) } } From 14321b3286d34a36720215e5c48d7afdeb1fda89 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 17:35:11 -0400 Subject: [PATCH 10/10] Accept a value that implements Future after a wrapper for an Object leaf --- .../tools/resolver/ReturnValueUnwrapper.kt | 11 ++++++---- .../tools/NestedGenericWrapperTest.kt | 21 ++++++++++++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt index 9699d3bb..705316dd 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -46,9 +46,12 @@ internal class ReturnValueUnwrapper private constructor( val unwrapped = unwrapping.unwrapped val outermost = unwrapped.firstOrNull() as? Unwrapped.Wrapper val belowOutermost = if (outermost == null) unwrapped else unwrapped.drop(1) - val specificLeafClass = TypeUtils.getRawType(unwrapping.javaType, null)?.takeUnless { it == Any::class.java } + val leafClass = TypeUtils.getRawType(unwrapping.javaType, null) + val specificLeafClass = leafClass?.takeUnless { it == Any::class.java } + val awaits = Awaitable(method, specificLeafClass, isSuspendFunction) + val peels = Awaitable(method, leafClass, isSuspendFunction) // graphql-java completes a subscription's events one at a time - val nested = if (isSubscription) null else steps(belowOutermost, Awaitable(method, specificLeafClass, isSuspendFunction)) + val nested = if (isSubscription) null else steps(belowOutermost, awaits, peels) return ReturnValueUnwrapper(genericWrappers, Awaitable(method, valueClass, isSuspendFunction), outermost?.isFuture == true, nested) } } @@ -93,10 +96,10 @@ private class ValueOf(private val present: Step) : Step { private val Unwrapped.Wrapper.isFuture get() = Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type) -private fun steps(unwrapped: List, awaitable: Awaitable): Step? = +private fun steps(unwrapped: List, awaits: Awaitable, peels: Awaitable): Step? = unwrapped.foldRight(null as Step?) { it, next -> when (it) { - is Unwrapped.Wrapper -> if (it.isFuture) Await(it.wrapper, awaitable, next) else Peel(it.wrapper, awaitable, next) + is Unwrapped.Wrapper -> if (it.isFuture) Await(it.wrapper, awaits, next) else Peel(it.wrapper, peels, next) Unwrapped.Data -> next?.let(::DataOf) Unwrapped.Present -> next?.let(::ValueOf) Unwrapped.Elements -> next?.let(::EachElement) diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index 947d54a9..79d1bb87 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -136,6 +136,10 @@ class NestedGenericWrapperTest { fun `fails a list of futures graphql-java can't wait on typed as Object`() = assertFails("listOfPlainFuturesOfAny", cantWaitOn("listOfPlainFuturesOfAny"), selection = "{ ... on Payload { data } }") + @Test + fun `resolves a union member that implements Future in a list of wrappers`() = + assertResolves("listOfWrappedItems", ab, selection = "{ ... on Payload { data } ... on FutureItem { data } }") + @Test fun `fails a list of wrappers whose transformer returns a future graphql-java can't wait on`() = assertFails("listOfLater", cantWaitOn("listOfLater")) @@ -162,7 +166,8 @@ class NestedGenericWrapperTest { assertEquals(execute("json", schema = schema, selection = "").errors, listOf()) assertEquals(execute("futureJson", schema = schema, selection = "").errors, listOf()) assertEquals(execute("jsonFutures", schema = schema, selection = "").errors, listOf()) - assertEquals(json, listOf(mapOf("a" to 1), mapOf("b" to 2), mapOf("c" to 3), mapOf("d" to 4), mapOf("e" to 5))) + assertEquals(execute("wrappedJsonOfAny", schema = schema, selection = "").errors, listOf()) + assertEquals(json, listOf(mapOf("a" to 1), mapOf("b" to 2), mapOf("c" to 3), mapOf("d" to 4), mapOf("e" to 5), FutureLeaf("f"))) } @Test @@ -294,6 +299,7 @@ class NestedGenericWrapperTest { futureOfListWithFailedWrapper: [Payload] listOfPlainFutures: [Payload] listOfPlainFuturesOfAny: [Item] + listOfWrappedItems: [Item!]! listOfLater: [Payload] suspendListOfPlainFutures: [Payload] listOfWrappedPending: [Payload!]! @@ -302,18 +308,23 @@ class NestedGenericWrapperTest { plainList: [Payload!]! futureOfList: [Payload!]! wrappedFuture: Payload! - ${if (scalars.isEmpty()) "" else "json: [JSON] futureJson: JSON jsonFutures: [JSON]"} + ${if (scalars.isEmpty()) "" else "json: [JSON] futureJson: JSON jsonFutures: [JSON] wrappedJsonOfAny: [JSON]"} } type Payload { data: String! } - union Item = Payload + type FutureItem { + data: String! + } + + union Item = Payload | FutureItem ${if (scalars.isEmpty()) "" else "scalar JSON"} """) .resolvers(query) + .dictionary(FutureItem::class) .scalars(*scalars) .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper, batchWrapper, boxWrapper, specialBoxWrapper, laterWrapper, pendingWrapper).build()) .build() @@ -370,6 +381,8 @@ class NestedGenericWrapperTest { data class FutureLeaf(val data: String) : Future by completedFuture(data) + data class FutureItem(val data: String) : Future by completedFuture(data) + class Query : GraphQLQueryResolver { val plainList = listOf(Payload("a")) val futureOfList: CompletableFuture> = completedFuture(plainList) @@ -409,12 +422,14 @@ class NestedGenericWrapperTest { fun futureOfListWithFailedWrapper(): CompletableFuture>> = CompletableFuture.supplyAsync { listWithFailedWrapper() } fun listOfPlainFutures(): List> = listOf(FutureTask { Payload("a") }.apply { run() }) fun listOfPlainFuturesOfAny(): List> = listOf(FutureTask { Payload("a") }.apply { run() }) + fun listOfWrappedItems(): List> = listOf(Try.success(Payload("a")), Try.success(FutureItem("b"))) fun listOfLater(): List> = listOf(Later(Payload("a"))) suspend fun suspendListOfPlainFutures(): List> = listOfPlainFutures() fun listOfWrappedPending(): List>> = listOf(Try.success(Pending(Payload("a"))), Try.success(Pending(Payload("b")))) fun json(): List>> = listOf(Try.success(mapOf("a" to 1)), Try.success(mapOf("b" to 2))) fun futureJson(): CompletableFuture>> = completedFuture(Try.success(mapOf("c" to 3))) fun jsonFutures(): List>> = listOf(completedFuture(mapOf("d" to 4)), CompletableFuture.supplyAsync { mapOf("e" to 5) }) + fun wrappedJsonOfAny(): List> = listOf(Try.success(FutureLeaf("f"))) fun batchOfPayloads(): Batch = Batch(listOfFutures()) fun listOfBoxes(): List> = listOf(Box(Payload("a")), SpecialBox(Payload("b"))) }