Skip to content

Fix suspend resolver context and Kotlin extension resolvers - #835

Merged
oryan-block merged 3 commits into
masterfrom
bugfix/318
Oct 4, 2026
Merged

oryan-block merged 3 commits into
masterfrom
bugfix/318

Conversation

@oryan-block

@oryan-block oryan-block commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #318
Fixes #272

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

A suspend resolver whose last parameter is the custom context class, like suspend fun myQuery(context: MyCustomContextType): Boolean from #318, builds fine but fails every time it's queried. The reporter got a ClassCastException, on master it's IllegalArgumentException: argument type mismatch. Same thing with GraphQLContext as the last parameter. MethodFieldResolver#createDataFetcher picks what to pass for that trailing argument with when (method.parameterTypes.last()). For a suspend function the last JVM parameter is the Continuation, so neither the contextClass nor the GraphQLContext branch matched and the else passed the DataFetchingEnvironment. It now looks at method.parameterTypes[numberOfParameters - 1], which is the last parameter before the Continuation.

Kotlin extension functions in a resolver, like fun Service.healthy() in a GraphQLResolver<Service> from #272, worked in 5.2.0 and fail since 5.6.0. FieldResolverScanner#getMethodParameterCount and MethodFieldResolver.numberOfParameters counted parameters with kotlinFunction.valueParameters, which leaves out the extension receiver. So fun Service.healthy() counted 0 parameters instead of 1. Depending on the arity, the extension was either skipped (a FieldResolverError, or the data class's own getter was used instead) or matched with the wrong count and failed with "wrong number of arguments" at query time. Both now use a shared parameterCountWithoutContinuation() util, which is the JVM parameter count minus the Continuation for suspend functions, so the receiver takes the source object. The last parameter is read from parameterTypes at that count too, otherwise a suspend extension and a non-suspend one were checked differently.

An extension receiver is only accepted as the source though. When there's no source parameter to match (root resolvers and data classes), verifyMethodArguments now rejects extension functions. Without that, the new count would let a helper like fun DataFetchingEnvironment.user() on a second root resolver collide with the real UserQuery#user(id), and the build would fail with "Found more than one matching resolver".

I looked at #418 too but didn't fix it. With contextClass set, myMutation(String id, String field, CustomContext context) is accepted for myMutation(id, field, persist) because the context class is allowed as the last parameter. A build-time check could reject resolvers that work today. contextClass can be any type, like Map or a class Jackson can build from the argument value, so a schema argument of that type in the last slot is valid (e.g. contextClass(Map::class) with a Java resolver taking a Map input). Limiting the check to DataFetchingEnvironment and GraphQLContext would miss the contextClass case the issue is about, and whether to check only the last slot or every slot is a call for maintainers. I also didn't touch how suspend resolvers are started (#419).

Behaviour change: suspend resolvers whose last parameter is GraphQLContext or the configured contextClass now get that object instead of failing. Public extension functions fun T.field() in a GraphQLResolver<T> are now matched as resolver methods, so if the data class has a getter with the same name, or the resolver also has getField(t: T), the extension now wins. 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 "wrong number of arguments" at query time. They're now skipped during matching. So a field whose only match was such a helper now fails at build time with FieldResolverError (unless allowUnimplementedResolvers is set) instead of failing every time it's queried. The last parameter is now checked by its raw class, so a Kotlin resolver with a parameterized context type like ctx: Map<String, Any> and contextClass(Map::class) is now accepted, same as Java resolvers already were. Java resolvers and regular Kotlin methods count parameters the same as before.

🤖 Generated with Claude Code

oryan-block and others added 3 commits October 3, 2026 18:35
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>
# Conflicts:
#	src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt
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) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Oct 4, 2026

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit d30b8eb into master Oct 4, 2026
6 checks passed
@oryan-block
oryan-block deleted the bugfix/318 branch October 4, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClassCastException when a resolver is simultaneously suspending & using the custom context class. Using Kotlin extension functions in Resolvers

1 participant