From 197edbd2e97b9c3b02c2fb562b515f360022d85b Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 3 Oct 2026 18:17:59 -0400 Subject: [PATCH 1/2] Fix suspend resolver context and Kotlin extension resolvers A suspend resolver taking GraphQLContext or the custom context class as its last argument failed at runtime with "argument type mismatch". MethodFieldResolver picked the type of the last JVM parameter to decide what to pass, but for a suspend function that is the Continuation, so it fell through to passing the DataFetchingEnvironment. It now looks at the last counted parameter instead. Kotlin extension functions in a GraphQLResolver could not be used as resolver methods because parameters were counted with kotlinFunction.valueParameters, which leaves out the extension receiver. They were either skipped or matched with the wrong arity and failed at runtime. FieldResolverScanner and MethodFieldResolver now count every parameter except the instance, which matches the JVM parameters minus the Continuation, so the receiver takes the source object. An extension receiver is only accepted in that source position. Extension helpers on root resolvers and data classes used to be matched when their arity happened to fit, and then either collided with the real resolver method at build time or failed with the wrong number of arguments at query time. They are now skipped during matching. Fixes #318 Fixes #272 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tools/resolver/FieldResolverScanner.kt | 18 +++++-- .../tools/resolver/MethodFieldResolver.kt | 7 +-- .../tools/FieldResolverScannerTest.kt | 25 ++++++++++ .../MethodFieldResolverDataFetcherTest.kt | 48 +++++++++++++++++++ 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt index 8939bd00..a15b7351 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt @@ -16,7 +16,8 @@ import org.apache.commons.lang3.reflect.TypeUtils import org.reactivestreams.Publisher import org.slf4j.LoggerFactory import java.lang.reflect.* -import kotlin.reflect.full.valueParameters +import kotlin.reflect.KParameter +import kotlin.reflect.full.extensionReceiverParameter import kotlin.reflect.jvm.javaType import kotlin.reflect.jvm.kotlinFunction @@ -160,7 +161,8 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) { it == search.requiredFirstParameterType || method.declaringClass.typeParameters.contains(it) } ?: false } else { - true + // an extension receiver can only take the source object + !isExtensionFunction(method) } val methodParameterCount = getMethodParameterCount(method) @@ -173,7 +175,7 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) { private fun getMethodParameterCount(method: Method): Int { return try { - method.kotlinFunction?.valueParameters?.size ?: method.parameterCount + method.kotlinFunction?.parameters?.count { it.kind != KParameter.Kind.INSTANCE } ?: method.parameterCount } catch (e: InternalError) { method.parameterCount } @@ -181,13 +183,21 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) { private fun getMethodLastParameter(method: Method): Type? { return try { - method.kotlinFunction?.valueParameters?.lastOrNull()?.type?.javaType + method.kotlinFunction?.parameters?.lastOrNull { it.kind != KParameter.Kind.INSTANCE }?.type?.javaType ?: method.parameterTypes.lastOrNull() } catch (e: InternalError) { method.parameterTypes.lastOrNull() } } + private fun isExtensionFunction(method: Method): Boolean { + return try { + method.kotlinFunction?.extensionReceiverParameter != null + } catch (e: InternalError) { + false + } + } + private fun findResolverProperty(field: FieldDefinition, search: Search) = FieldUtils.getAllFields(search.type.unwrap()).find { it.name == field.name } diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index 926ebee4..bc534b27 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -24,7 +24,7 @@ import java.lang.reflect.Method import java.util.* import java.util.function.Supplier import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn -import kotlin.reflect.full.valueParameters +import kotlin.reflect.KParameter import kotlin.reflect.jvm.javaType import kotlin.reflect.jvm.kotlinFunction @@ -41,7 +41,7 @@ internal class MethodFieldResolver( private val log = LoggerFactory.getLogger(javaClass) private val isSuspendFunction = method.isSuspendFunction() - private val numberOfParameters = method.kotlinFunction?.valueParameters?.size ?: method.parameterCount + private val numberOfParameters = method.kotlinFunction?.parameters?.count { it.kind != KParameter.Kind.INSTANCE } ?: method.parameterCount private val hasAdditionalParameter = numberOfParameters == (field.inputValueDefinitions.size + getIndexOffset() + 1) override fun createDataFetcher(): DataFetcher<*> { @@ -100,7 +100,8 @@ internal class MethodFieldResolver( // Add DataFetchingEnvironment/Context argument if (this.hasAdditionalParameter) { - when (this.method.parameterTypes.last()) { + // suspend functions have a trailing Continuation parameter + when (this.method.parameterTypes[numberOfParameters - 1]) { null -> throw ResolverError("Expected at least one argument but got none, this is most likely a bug with graphql-java-tools") options.contextClass -> args.add { environment -> val context: Any? = environment.graphQlContext[options.contextClass] diff --git a/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt b/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt index e5631844..8e8dd52b 100644 --- a/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt @@ -6,10 +6,12 @@ import graphql.kickstart.tools.resolver.FieldResolverScanner import graphql.kickstart.tools.resolver.MethodFieldResolver import graphql.kickstart.tools.resolver.PropertyFieldResolver import graphql.language.FieldDefinition +import graphql.language.InputValueDefinition import graphql.language.TypeName import graphql.relay.Connection import graphql.relay.DefaultConnection import graphql.relay.DefaultPageInfo +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Test import java.util.* @@ -98,6 +100,21 @@ class FieldResolverScannerTest { Locale.setDefault(default) } + @Test + fun `scanner ignores extension functions on root resolvers`() { + val resolver = RootResolverInfo(listOf(UserQuery(), ExtensionQuery()), options) + val field = FieldDefinition.newFieldDefinition() + .name("user") + .type(TypeName("String")) + .inputValueDefinition(InputValueDefinition("id", TypeName("ID"))) + .build() + + val user = scanner.findFieldResolver(field, resolver) + + assert(user is MethodFieldResolver) + assertEquals((user as MethodFieldResolver).method.declaringClass, UserQuery::class.java) + } + class RootQuery1 : GraphQLQueryResolver { fun field1() {} } @@ -110,6 +127,14 @@ class FieldResolverScannerTest { fun field1() {} } + class UserQuery : GraphQLQueryResolver { + fun user(id: String): String = id + } + + class ExtensionQuery : GraphQLQueryResolver { + fun DataFetchingEnvironment.user(): String = field.name + } + class CamelCaseQuery1 : GraphQLQueryResolver { fun getHullType(): HullType = HullType() } diff --git a/src/test/kotlin/graphql/kickstart/tools/MethodFieldResolverDataFetcherTest.kt b/src/test/kotlin/graphql/kickstart/tools/MethodFieldResolverDataFetcherTest.kt index 91464566..f231e558 100644 --- a/src/test/kotlin/graphql/kickstart/tools/MethodFieldResolverDataFetcherTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/MethodFieldResolverDataFetcherTest.kt @@ -212,6 +212,54 @@ class MethodFieldResolverDataFetcherTest { assertEquals(resolver.get(createEnvironment(DataClass(), context = context)), true) } + @Test + fun `data fetcher passes context to suspend function if method has extra argument and context is specified`() { + val context = GraphQLContext.newContext().build() + val resolver = createFetcher("active", resolver = object : GraphQLResolver { + suspend fun isActive(dataClass: DataClass, ctx: GraphQLContext): Boolean { + return ctx == context + } + }) + + @Suppress("UNCHECKED_CAST") + val future = resolver.get(createEnvironment(DataClass(), context = context)) as CompletableFuture + assert(future.get()) + } + + @Test + fun `data fetcher passes custom context to suspend function if method has extra argument and custom context is specified`() { + val customContext = ContextClass() + val context = GraphQLContext.of(mapOf(ContextClass::class.java to customContext)) + val options = SchemaParserOptions.newOptions().contextClass(ContextClass::class).build() + val resolver = createFetcher("active", options = options, resolver = object : GraphQLResolver { + suspend fun isActive(dataClass: DataClass, ctx: ContextClass): Boolean { + return ctx == customContext + } + }) + + @Suppress("UNCHECKED_CAST") + val future = resolver.get(createEnvironment(DataClass(), context = context)) as CompletableFuture + assert(future.get()) + } + + @Test + fun `data fetcher uses extension function on the data class`() { + val resolver = createFetcher("name", object : GraphQLResolver { + fun DataClass.name(): String = "extension $name" + }) + + assertEquals(resolver.get(createEnvironment(DataClass())), "extension TestName") + } + + @Test + fun `data fetcher passes environment to extension function if method has extra argument`() { + val resolver = createFetcher("active", object : GraphQLResolver { + fun DataClass.isActive(env: DataFetchingEnvironment): Boolean = env is DataFetchingEnvironment + }) + + assertEquals(resolver.get(createEnvironment(DataClass())), true) + } + @Test fun `data fetcher marshalls input object if required`() { val name = "correct name" From 0fdfd991a41046749edc4f4c1e96c0394a93008a Mon Sep 17 00:00:00 2001 From: Oryan Date: Sun, 4 Oct 2026 14:00:49 -0400 Subject: [PATCH 2/2] Extract resolver parameter count into a util Count JVM parameters minus the suspend Continuation instead of going through kotlin-reflect's parameter list, and read the last parameter from parameterTypes at that index. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tools/resolver/FieldResolverScanner.kt | 23 ++----------------- .../tools/resolver/MethodFieldResolver.kt | 13 +++-------- .../graphql/kickstart/tools/util/Utils.kt | 13 +++++++++++ 3 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt index 26541782..173cc4a3 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt @@ -16,9 +16,7 @@ import org.apache.commons.lang3.reflect.TypeUtils import org.reactivestreams.Publisher import org.slf4j.LoggerFactory import java.lang.reflect.* -import kotlin.reflect.KParameter import kotlin.reflect.full.extensionReceiverParameter -import kotlin.reflect.jvm.javaType import kotlin.reflect.jvm.kotlinFunction /** @@ -172,31 +170,14 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) { !isExtensionFunction(method) } - val methodParameterCount = getMethodParameterCount(method) - val methodLastParameter = getMethodLastParameter(method) + val methodParameterCount = method.parameterCountWithoutContinuation() + val methodLastParameter = method.parameterTypes.getOrNull(methodParameterCount - 1) val correctParameterCount = methodParameterCount == requiredCount || (methodParameterCount == (requiredCount + 1) && allowedLastArgumentTypes.contains(methodLastParameter)) return correctParameterCount && appropriateFirstParameter } - private fun getMethodParameterCount(method: Method): Int { - return try { - method.kotlinFunction?.parameters?.count { it.kind != KParameter.Kind.INSTANCE } ?: method.parameterCount - } catch (e: InternalError) { - method.parameterCount - } - } - - private fun getMethodLastParameter(method: Method): Type? { - return try { - method.kotlinFunction?.parameters?.lastOrNull { it.kind != KParameter.Kind.INSTANCE }?.type?.javaType - ?: method.parameterTypes.lastOrNull() - } catch (e: InternalError) { - method.parameterTypes.lastOrNull() - } - } - private fun isExtensionFunction(method: Method): Boolean { return try { method.kotlinFunction?.extensionReceiverParameter != null diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index 89c22c18..b280c463 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -7,6 +7,8 @@ import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper import graphql.kickstart.tools.util.JavaType import graphql.kickstart.tools.util.coroutineScope import graphql.kickstart.tools.util.futureValueType +import graphql.kickstart.tools.util.isSuspendFunction +import graphql.kickstart.tools.util.parameterCountWithoutContinuation import graphql.kickstart.tools.util.typeArgument import graphql.kickstart.tools.util.unwrap import graphql.language.* @@ -26,7 +28,6 @@ import java.lang.reflect.Method import java.util.* import java.util.function.Supplier import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn -import kotlin.reflect.KParameter import kotlin.reflect.jvm.javaType import kotlin.reflect.jvm.kotlinFunction @@ -43,7 +44,7 @@ internal class MethodFieldResolver( private val log = LoggerFactory.getLogger(javaClass) private val isSuspendFunction = method.isSuspendFunction() - private val numberOfParameters = method.kotlinFunction?.parameters?.count { it.kind != KParameter.Kind.INSTANCE } ?: method.parameterCount + private val numberOfParameters = method.parameterCountWithoutContinuation() private val hasAdditionalParameter = numberOfParameters == (field.inputValueDefinitions.size + getIndexOffset() + 1) override fun createDataFetcher(): DataFetcher<*> { @@ -283,14 +284,6 @@ private class CompareGenericWrappers { } } -private fun Method.isSuspendFunction(): Boolean { - return try { - this.kotlinFunction?.isSuspend == true - } catch (e: InternalError) { - false - } -} - private suspend inline fun invokeSuspend(target: Any, resolverMethod: Method, args: Array): Any? { return suspendCoroutineUninterceptedOrReturn { continuation -> invoke(resolverMethod, target, args + continuation) diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index 58c6ef40..ae8dd5c4 100644 --- a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt +++ b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt @@ -12,6 +12,7 @@ import java.lang.reflect.ParameterizedType import java.lang.reflect.Proxy import java.util.concurrent.CompletionStage import java.util.concurrent.Future +import kotlin.reflect.jvm.kotlinFunction /** * @author Andrew Potter @@ -59,6 +60,18 @@ internal val Class<*>.declaredNonProxyMethods: List } } +internal fun JavaMethod.isSuspendFunction(): Boolean { + return try { + this.kotlinFunction?.isSuspend == true + } catch (e: InternalError) { + false + } +} + +// the trailing Continuation of a suspend function isn't a resolver argument +internal fun JavaMethod.parameterCountWithoutContinuation(): Int = + if (isSuspendFunction()) parameterCount - 1 else parameterCount + internal fun getDocumentation(node: AbstractNode<*>, options: SchemaParserOptions): String? = when { node is AbstractDescribedNode<*> && node.description != null -> node.description?.content