Skip to content

Call JDK proxied resolver methods through the proxy's interfaces - #853

Open
oryan-block wants to merge 2 commits into
masterfrom
bugfix/822
Open

oryan-block wants to merge 2 commits into
masterfrom
bugfix/822

Conversation

@oryan-block

Copy link
Copy Markdown
Collaborator

Fixes #822

Checklist

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

Description

A resolver behind a JDK dynamic proxy, like Spring AOP without proxyTargetClass, builds fine but fails on every field at query time. The repro from the issue is a ProxiedQuery : GraphQLQueryResolver with fun test() = "ok", wrapped with ProxyFactory(ProxiedQuery()).proxy. { test } returns data={test=null} with this error:

IllegalArgumentException: object of type jdk.proxy2.$Proxy11 is not an instance of com.example.ProxiedQuery

The same resolver behind a CGLIB proxy works.

Spring4AopProxyHandler#getTargetClass returns the target class, so FieldResolverScanner finds the resolver methods on ProxiedQuery. MethodFieldResolver then invokes that class's Method on the proxy, which only implements the target's interfaces. Calling the method on the unwrapped target instead would skip the proxy's interceptors (@PreAuthorize and friends), so I didn't do that.

Now FieldResolverScanner#findInvocableMethod picks the Method to invoke. It returns the scanned method unchanged when there's no source, when the method is static, or when the source is an instance of the method's declaring class. That covers plain resolvers, data classes, and CGLIB, Guice, Javassist and Weld proxies. Otherwise it looks at the target class's interfaces that the proxy actually implements, and takes the first non-static interface method that the scanned method implements. The call goes through the proxy, so interceptors still run. The interface method is made accessible, since non-public interfaces need it. MethodFieldResolver gets the result as a new invocableMethod constructor parameter and passes it to both data fetchers. Everything type-related still uses the scanned method on the target class.

Matching is done by Method.implements: same name, same parameter count, and parameter types that agree once both sides are resolved against the target class with TypeUtils.getRawType (falling back to the erasure). That's needed for generic interfaces, where EchoApi<String>.echo(T) is implemented by echo(String), and for methods inherited from a generic superclass, where FooResolver<T>.getValue(T) is erased to getValue(Object). When the interface parameter is a type variable, primitives are boxed first, because Kotlin compiles Api<Int>.increment to increment(int).

If no interface method matches, build() throws a FieldResolverError that names the method, the field, the schema location and the proxy class, and suggests declaring the method on an interface or using class-based proxies. That's the issue repro:

Resolver method 'ProxiedResolverTest$ClassOnlyQuery.test' for field 'test' as defined in schema :3 can't be called on jdk.proxy2.$ProxyN, which is not an instance of ProxiedResolverTest$ClassOnlyQuery. This happens with JDK dynamic proxies, which only implement interfaces. Declare the method on an interface the resolver implements, or use class-based proxies (e.g. proxyTargetClass = true in Spring).

(Package names trimmed, and the proxy number varies.) The issue also suggested calling through Proxy.getInvocationHandler(proxy). Going through the interface method gets the same result with plain reflection and doesn't depend on what the handler is.

findFieldResolver now evaluates the searches lazily for non-root fields and stops at the first match. Without that, a JDK-proxied supertype resolver with a class-only method would fail the build even when a more specific resolver takes precedence for that field. Root fields still evaluate every search for the duplicate check. The source location formatting moved into a shared getSourceLocation, and the missing-resolver message is unchanged.

The new ProxiedResolverTest builds real proxies with Spring's ProxyFactory and a recording MethodInterceptor, and runs queries. It covers root methods on the light and regular data fetcher paths, a suspend function, an interceptor that rejects the call, field resolvers on the NormalResolverInfo and MultiResolverInfo paths, a more specific resolver shadowing a proxied supertype resolver, generic interfaces including Kotlin Int, field resolver methods inherited from a generic superclass (through a generic and a non-generic interface), a non-public interface, static methods and a CGLIB proxy. Three tests check the build error: a class-only method (exact message), a method only on an interface the proxy doesn't expose (setInterfaces), and a method only matched by a static interface method. On master the runtime cases fail with the "is not an instance of" error, the rejecting interceptor never runs, and the class-only case builds without throwing. The generic superclass cases fail on the first commit with the new FieldResolverError. The static method, shadowed supertype and CGLIB tests pass on master too and are there as guards. I also checked that putting back the eager search, or dropping the trySetAccessible call, the isInstance filter or the static guard on interface methods, each makes its test fail. I removed the build-only SchemaParserTest test "parser handles spring AOP proxied resolvers by default" and its fixture, since the new tests replace it. clean verify passes.

Left out:

  • Field-backed resolvers on JDK proxies, e.g. a Java public field or Kotlin property matching a GraphQL field, still fail at runtime. Throwing at build would break the data class fallback, and skipping the field gives a confusing "No method or field found" error. That could be a follow-up.
  • A generic interface whose type argument is still a type variable of the target class (class Q<T : Number> : Api<T>, scanned as raw Q) can't be resolved by TypeUtils, so it still gets the build error.
  • Root resolver arguments typed by a superclass type variable (abstract class Base<T> { fun echo(value: T) }) fail at runtime with a ClassCastException from TypeVariableImpl to Class even without a proxy. That's an existing argument conversion limitation, not a proxy one.
  • No test for a Java foo(int)/foo(Integer) overload pair. Which one the scanner picks depends on getDeclaredMethods order, so the test wouldn't be deterministic.
  • Suspend functions through a JDK proxy are only tested with spring-aop 5.3.39, the version in the pom. Spring 6.1+ with kotlinx-coroutines-reactor bridges suspend functions itself, and I haven't tried that.

#849 touches FieldResolverScanner and MethodFieldResolver too, but git merge-tree shows no conflicts with it or with the other open PRs.

Behaviour change: only resolvers behind JDK dynamic proxies with a ProxyHandler that reports a target class the proxy isn't an instance of (in practice Spring4AopProxyHandler) are affected. Fields whose method is declared on an interface the proxy implements now work and run the interceptors. Non-static methods that aren't declared on any interface the proxy implements now make build() throw a FieldResolverError instead of failing on every call. So an app with such a field that's never queried will now fail at startup, which probably needs a release note and a minor version. That error is thrown even with allowUnimplementedResolvers or missingResolverDataFetcher set, because the resolver exists but can't be called. Static methods on JDK proxies are still called directly and skip interceptors, as before. On JDK-proxied resolvers, "can't wait on" errors now name the interface method, e.g. QueryApi.test. For non-root fields, data class methods shadowed by a resolver are no longer made accessible. The chosen resolver is the same. Nothing changes for plain resolvers, data classes, class-based proxies or JDK proxies without a ProxyHandler. MethodFieldResolver, FieldResolverScanner and FieldResolverError are internal, so there's no public API change.

🤖 Generated with Claude Code

oryan-block and others added 2 commits October 12, 2026 15:30
Resolvers behind a JDK dynamic proxy, e.g. Spring AOP without proxyTargetClass, are scanned as their target class, but the proxy only implements the target's interfaces, so every field failed at runtime with "object of type jdk.proxy2.$ProxyN is not an instance of ...". Each resolver method is now called through the interface method it implements, so the proxy's interceptors still run, and build() fails with a clear error if no interface declares it. Static methods are still called directly.

Only the first matching resolver of a non-root field is checked, so a supertype resolver's method that a more specific resolver takes precedence over doesn't fail the build.

Fixes #822

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A resolver method inherited from a generic superclass, e.g. getValue(T) of FooResolver<T>, is erased to getValue(Object), so it didn't match the interface method it implements and build() threw the new FieldResolverError for a supported resolver pattern. Its parameter types are now resolved against the target class too, like the interface's.

Also covers the guards that skip interfaces the proxy doesn't implement and static interface methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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.

Resolvers behind a JDK dynamic proxy build fine but fail on every field at runtime

1 participant