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..44f7c64a 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,72 @@ 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 +127,13 @@ 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..9050738c 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, returnType, valueClass, field.type, search.isSubscription, typeResolver, options.genericWrappers, scalarNames - ) + 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 @@ -160,8 +158,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 +180,13 @@ internal class MethodFieldResolver( typeResolver.resolveTypeVariables(declaredType) } - private fun getResolvedReturnType(): JavaType = - typeResolver.resolve(if (search.isSubscription) returnType.asPublisher() else returnType) + 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..705316dd 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ReturnValueUnwrapper.kt @@ -1,21 +1,14 @@ 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.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.kickstart.tools.TypeClassMatcher.Unwrapped +import graphql.kickstart.tools.TypeClassMatcher.Unwrapping import graphql.schema.DataFetchingEnvironment -import graphql.schema.idl.ScalarInfo import kotlinx.coroutines.future.asDeferred +import org.apache.commons.lang3.reflect.TypeUtils 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 @@ -24,45 +17,42 @@ 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? = - value.transformWithGenericWrapper(genericWrappers, environmentSupplier) - .requireAwaitable(valueClass, method, isSuspendFunction) - .then(nested, environmentSupplier) - companion object { fun create( method: Method, - returnType: JavaType, + unwrapping: Unwrapping, valueClass: Class<*>?, - fieldType: GraphQLLangType, + isSuspendFunction: Boolean, isSubscription: Boolean, - typeResolver: GenericTypeResolver, - genericWrappers: List, - scalarNames: Set + genericWrappers: List ): 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) - } - return ReturnValueUnwrapper(genericWrappers, method, valueClass, nested) + 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) + 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, awaits, peels) + return ReturnValueUnwrapper(genericWrappers, Awaitable(method, valueClass, isSuspendFunction), outermost?.isFuture == true, nested) } } } @@ -71,9 +61,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 wrapper: GenericWrapper, private val awaitable: Awaitable, private val next: Step?) : Step { override fun unwrap(value: Any?, environmentSupplier: Supplier): Any? = - value?.transformWithGenericWrapper(genericWrappers, environmentSupplier).then(next, environmentSupplier) + awaitable.requireNested(value?.let { wrapper.transformer(it, environmentSupplier.get()) }).then(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 { @@ -96,64 +93,21 @@ 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 - } - } - - fun isFutureReturnedByWrapperBefore(i: Int) = - (unwrapped[i] as? Unwrapped.Wrapper)?.isFuture == true && (unwrapped.getOrNull(i - 1) as? Unwrapped.Wrapper)?.isFuture == false +private val Unwrapped.Wrapper.isFuture + get() = Future::class.java.isAssignableFrom(wrapper.type) || CompletionStage::class.java.isAssignableFrom(wrapper.type) - 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 -> +private fun steps(unwrapped: List, awaits: Awaitable, peels: Awaitable): Step? = + unwrapped.foldRight(null as Step?) { it, next -> when (it) { - is Unwrapped.Wrapper -> Peel(genericWrappers, 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) } } -} -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?.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? = if (next == null) this else then { next.unwrap(it, environmentSupplier) } @@ -195,25 +149,27 @@ 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 - } +private class Awaitable(private val method: Method, private val valueClass: Class<*>?, private val isSuspendFunction: Boolean) { - val resolverName = "${method.declaringClass.name}.${method.name}" - if (isSuspendFunction) { - throw ResolverError("Suspend function '$resolverName' resolved to a ${javaClass.name}, which graphql-java can't wait on. Return its value instead.") - } - if (this !is CompletionStage<*>) { - throw ResolverError( - "Resolver method '$resolverName' resolved to a ${javaClass.name}, which graphql-java can't wait on. " + - "Return a CompletionStage, such as a CompletableFuture, from the method or its generic wrapper transformer instead." - ) - } + fun requireOutermost(value: Any?): Any? = + if (value !is Future<*> || valueClass?.isInstance(value) == true || value is CompletionStage<*> && !isSuspendFunction) value else fail(value) - return this + fun requireNested(value: Any?): Any? = + if (value !is Future<*> || value is CompletionStage<*> || valueClass?.isInstance(value) == true) value else fail(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 + 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 0684b81f..79d1bb87 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 @@ -127,23 +130,44 @@ 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") + fun `fails a list of futures graphql-java can't wait on`() = assertFails("listOfPlainFutures", cantWaitOn("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}'" - )) - } + @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 `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")) + + @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 `gives a custom scalar the wrappers as they are`() { + 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) + + @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() 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(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 @@ -171,56 +195,67 @@ 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 containers = listOf(List::class.java, CompletableFuture::class.java, Try::class.java, Page::class.java, DataFetcherResult::class.java, Optional::class.java) + 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, 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 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 match = try { - matcher.match(TypeClassMatcher.PotentialMatch.returnValue(graphQLType, typeResolver.resolve(javaType), typeResolver, ScanDictionary.ReturnValueReference(method))) - } 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 } } - (match as? TypeClassMatcher.ValidMatch)?.javaType == Payload::class.java } - val failures = accepted.mapNotNull { (shape, javaType, graphQLType) -> + val failures = accepted.mapNotNull { (shape, potentialMatch) -> + val graphQLType = potentialMatch.graphQLType val unwrapper = ReturnValueUnwrapper.create( - method, javaType, TypeUtils.getRawType(typeResolver.resolve(javaType), null), graphQLType, false, typeResolver, options.genericWrappers, emptySet() + method, potentialMatch.unwrapping, TypeUtils.getRawType(typeResolver.resolve(potentialMatch.javaType), null), isSuspendFunction = false, isSubscription = 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()}" - .takeUnless { result.errors.isEmpty() && result.getData() == mapOf("field" to expected(graphQLType)) } + 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 }") + 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.size > graphQLTypes.size) + assertTrue(accepted.count { (_, potentialMatch) -> leaf(potentialMatch.graphQLType) is FutureLeaf } > 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") 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 -> 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 } 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() } } } 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() @@ -231,7 +266,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() @@ -263,21 +298,35 @@ class NestedGenericWrapperTest { listWithFailedWrapper: [Payload] futureOfListWithFailedWrapper: [Payload] listOfPlainFutures: [Payload] + listOfPlainFuturesOfAny: [Item] + listOfWrappedItems: [Item!]! + listOfLater: [Payload] + suspendListOfPlainFutures: [Payload] + listOfWrappedPending: [Payload!]! + batchOfPayloads: [Payload!]! + listOfBoxes: [Payload!]! plainList: [Payload!]! futureOfList: [Payload!]! wrappedFuture: Payload! - ${if (scalars.isEmpty()) "" else "json: [JSON] futureJson: JSON"} + ${if (scalars.isEmpty()) "" else "json: [JSON] futureJson: JSON jsonFutures: [JSON] wrappedJsonOfAny: [JSON]"} } type Payload { data: String! } + 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).build()) + .options(SchemaParserOptions.newOptions().genericWrappers(tryWrapper, pageWrapper, batchWrapper, boxWrapper, specialBoxWrapper, laterWrapper, pendingWrapper).build()) .build() .makeExecutableSchema() @@ -294,8 +343,12 @@ class NestedGenericWrapperTest { assertEquals(result.getData(), mapOf(field to expected)) } - private fun assertFails(field: String, message: String) { - val result = execute(field) + 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, 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)) } @@ -326,6 +379,10 @@ class NestedGenericWrapperTest { data class Payload(val data: String) + 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) @@ -364,16 +421,49 @@ 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 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"))) } + class Batch(val items: List>) + + open class Box(val content: T) + + 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 }) + 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 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))) .build() + private val leafType = GraphQLScalarType.newScalar() + .name("Leaf") + .coercing(object : Coercing { + override fun serialize(dataFetcherResult: Any, graphQLContext: GraphQLContext, locale: Locale): String = + (dataFetcherResult as? FutureLeaf)?.data ?: throw CoercingSerializeException("Expected a FutureLeaf but got $dataFetcherResult") + }) + .build() } }