Skip to content

Commit 197edbd

Browse files
oryan-blockclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 62c5146 commit 197edbd

4 files changed

Lines changed: 91 additions & 7 deletions

File tree

‎src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import org.apache.commons.lang3.reflect.TypeUtils
1616
import org.reactivestreams.Publisher
1717
import org.slf4j.LoggerFactory
1818
import java.lang.reflect.*
19-
import kotlin.reflect.full.valueParameters
19+
import kotlin.reflect.KParameter
20+
import kotlin.reflect.full.extensionReceiverParameter
2021
import kotlin.reflect.jvm.javaType
2122
import kotlin.reflect.jvm.kotlinFunction
2223

@@ -160,7 +161,8 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
160161
it == search.requiredFirstParameterType || method.declaringClass.typeParameters.contains(it)
161162
} ?: false
162163
} else {
163-
true
164+
// an extension receiver can only take the source object
165+
!isExtensionFunction(method)
164166
}
165167

166168
val methodParameterCount = getMethodParameterCount(method)
@@ -173,21 +175,29 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
173175

174176
private fun getMethodParameterCount(method: Method): Int {
175177
return try {
176-
method.kotlinFunction?.valueParameters?.size ?: method.parameterCount
178+
method.kotlinFunction?.parameters?.count { it.kind != KParameter.Kind.INSTANCE } ?: method.parameterCount
177179
} catch (e: InternalError) {
178180
method.parameterCount
179181
}
180182
}
181183

182184
private fun getMethodLastParameter(method: Method): Type? {
183185
return try {
184-
method.kotlinFunction?.valueParameters?.lastOrNull()?.type?.javaType
186+
method.kotlinFunction?.parameters?.lastOrNull { it.kind != KParameter.Kind.INSTANCE }?.type?.javaType
185187
?: method.parameterTypes.lastOrNull()
186188
} catch (e: InternalError) {
187189
method.parameterTypes.lastOrNull()
188190
}
189191
}
190192

193+
private fun isExtensionFunction(method: Method): Boolean {
194+
return try {
195+
method.kotlinFunction?.extensionReceiverParameter != null
196+
} catch (e: InternalError) {
197+
false
198+
}
199+
}
200+
191201
private fun findResolverProperty(field: FieldDefinition, search: Search) =
192202
FieldUtils.getAllFields(search.type.unwrap()).find { it.name == field.name }
193203

‎src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import java.lang.reflect.Method
2424
import java.util.*
2525
import java.util.function.Supplier
2626
import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn
27-
import kotlin.reflect.full.valueParameters
27+
import kotlin.reflect.KParameter
2828
import kotlin.reflect.jvm.javaType
2929
import kotlin.reflect.jvm.kotlinFunction
3030

@@ -41,7 +41,7 @@ internal class MethodFieldResolver(
4141
private val log = LoggerFactory.getLogger(javaClass)
4242

4343
private val isSuspendFunction = method.isSuspendFunction()
44-
private val numberOfParameters = method.kotlinFunction?.valueParameters?.size ?: method.parameterCount
44+
private val numberOfParameters = method.kotlinFunction?.parameters?.count { it.kind != KParameter.Kind.INSTANCE } ?: method.parameterCount
4545
private val hasAdditionalParameter = numberOfParameters == (field.inputValueDefinitions.size + getIndexOffset() + 1)
4646

4747
override fun createDataFetcher(): DataFetcher<*> {
@@ -100,7 +100,8 @@ internal class MethodFieldResolver(
100100

101101
// Add DataFetchingEnvironment/Context argument
102102
if (this.hasAdditionalParameter) {
103-
when (this.method.parameterTypes.last()) {
103+
// suspend functions have a trailing Continuation parameter
104+
when (this.method.parameterTypes[numberOfParameters - 1]) {
104105
null -> throw ResolverError("Expected at least one argument but got none, this is most likely a bug with graphql-java-tools")
105106
options.contextClass -> args.add { environment ->
106107
val context: Any? = environment.graphQlContext[options.contextClass]

‎src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import graphql.kickstart.tools.resolver.FieldResolverScanner
66
import graphql.kickstart.tools.resolver.MethodFieldResolver
77
import graphql.kickstart.tools.resolver.PropertyFieldResolver
88
import graphql.language.FieldDefinition
9+
import graphql.language.InputValueDefinition
910
import graphql.language.TypeName
1011
import graphql.relay.Connection
1112
import graphql.relay.DefaultConnection
1213
import graphql.relay.DefaultPageInfo
14+
import graphql.schema.DataFetchingEnvironment
1315
import kotlinx.coroutines.ExperimentalCoroutinesApi
1416
import org.junit.Test
1517
import java.util.*
@@ -98,6 +100,21 @@ class FieldResolverScannerTest {
98100
Locale.setDefault(default)
99101
}
100102

103+
@Test
104+
fun `scanner ignores extension functions on root resolvers`() {
105+
val resolver = RootResolverInfo(listOf(UserQuery(), ExtensionQuery()), options)
106+
val field = FieldDefinition.newFieldDefinition()
107+
.name("user")
108+
.type(TypeName("String"))
109+
.inputValueDefinition(InputValueDefinition("id", TypeName("ID")))
110+
.build()
111+
112+
val user = scanner.findFieldResolver(field, resolver)
113+
114+
assert(user is MethodFieldResolver)
115+
assertEquals((user as MethodFieldResolver).method.declaringClass, UserQuery::class.java)
116+
}
117+
101118
class RootQuery1 : GraphQLQueryResolver {
102119
fun field1() {}
103120
}
@@ -110,6 +127,14 @@ class FieldResolverScannerTest {
110127
fun field1() {}
111128
}
112129

130+
class UserQuery : GraphQLQueryResolver {
131+
fun user(id: String): String = id
132+
}
133+
134+
class ExtensionQuery : GraphQLQueryResolver {
135+
fun DataFetchingEnvironment.user(): String = field.name
136+
}
137+
113138
class CamelCaseQuery1 : GraphQLQueryResolver {
114139
fun getHullType(): HullType = HullType()
115140
}

‎src/test/kotlin/graphql/kickstart/tools/MethodFieldResolverDataFetcherTest.kt‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,54 @@ class MethodFieldResolverDataFetcherTest {
212212
assertEquals(resolver.get(createEnvironment(DataClass(), context = context)), true)
213213
}
214214

215+
@Test
216+
fun `data fetcher passes context to suspend function if method has extra argument and context is specified`() {
217+
val context = GraphQLContext.newContext().build()
218+
val resolver = createFetcher("active", resolver = object : GraphQLResolver<DataClass> {
219+
suspend fun isActive(dataClass: DataClass, ctx: GraphQLContext): Boolean {
220+
return ctx == context
221+
}
222+
})
223+
224+
@Suppress("UNCHECKED_CAST")
225+
val future = resolver.get(createEnvironment(DataClass(), context = context)) as CompletableFuture<Boolean>
226+
assert(future.get())
227+
}
228+
229+
@Test
230+
fun `data fetcher passes custom context to suspend function if method has extra argument and custom context is specified`() {
231+
val customContext = ContextClass()
232+
val context = GraphQLContext.of(mapOf(ContextClass::class.java to customContext))
233+
val options = SchemaParserOptions.newOptions().contextClass(ContextClass::class).build()
234+
val resolver = createFetcher("active", options = options, resolver = object : GraphQLResolver<DataClass> {
235+
suspend fun isActive(dataClass: DataClass, ctx: ContextClass): Boolean {
236+
return ctx == customContext
237+
}
238+
})
239+
240+
@Suppress("UNCHECKED_CAST")
241+
val future = resolver.get(createEnvironment(DataClass(), context = context)) as CompletableFuture<Boolean>
242+
assert(future.get())
243+
}
244+
245+
@Test
246+
fun `data fetcher uses extension function on the data class`() {
247+
val resolver = createFetcher("name", object : GraphQLResolver<DataClass> {
248+
fun DataClass.name(): String = "extension $name"
249+
})
250+
251+
assertEquals(resolver.get(createEnvironment(DataClass())), "extension TestName")
252+
}
253+
254+
@Test
255+
fun `data fetcher passes environment to extension function if method has extra argument`() {
256+
val resolver = createFetcher("active", object : GraphQLResolver<DataClass> {
257+
fun DataClass.isActive(env: DataFetchingEnvironment): Boolean = env is DataFetchingEnvironment
258+
})
259+
260+
assertEquals(resolver.get(createEnvironment(DataClass())), true)
261+
}
262+
215263
@Test
216264
fun `data fetcher marshalls input object if required`() {
217265
val name = "correct name"

0 commit comments

Comments
 (0)