Skip to content

Commit e083dc8

Browse files
committed
Support Callable as a controller method return value
Closes gh-316
1 parent 735da03 commit e083dc8

File tree

13 files changed

+275
-57
lines changed

13 files changed

+275
-57
lines changed

spring-graphql-docs/src/docs/asciidoc/index.adoc

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,8 +1227,13 @@ See <<controllers-schema-mapping-data-loader>>.
12271227

12281228
|===
12291229

1230-
Schema mapping handler methods can return any value, including Reactor `Mono` and
1231-
`Flux` as described in <<execution-reactive-datafetcher>>.
1230+
Schema mapping handler methods can return:
1231+
1232+
- A resolved value of any type.
1233+
- `Mono` and `Flux` for asynchronous value(s). Supported for controller methods and for
1234+
any `DataFetcher` as described in <<execution-reactive-datafetcher>>.
1235+
- `java.util.concurrent.Callable` to have the value(s) produced asynchronously.
1236+
For this to work, `AnnotatedControllerConfigurer` must be configured with an `Executor`.
12321237

12331238

12341239

@@ -1574,6 +1579,10 @@ Batch mapping methods can return:
15741579
| `Map<K,V>`, `Collection<V>`
15751580
| Imperative variants, e.g. without remote calls to make.
15761581

1582+
| `Callable<Map<K,V>>`, `Callable<Collection<V>>`
1583+
| Imperative variants to be invoked asynchronously. For this to work,
1584+
`AnnotatedControllerConfigurer` must be configured with an `Executor`.
1585+
15771586
|===
15781587

15791588

spring-graphql/src/main/java/org/springframework/graphql/data/method/InvocableHandlerMethodSupport.java

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,20 @@
1919
import java.lang.reflect.Method;
2020
import java.util.Arrays;
2121
import java.util.List;
22+
import java.util.concurrent.Callable;
23+
import java.util.concurrent.CompletableFuture;
24+
import java.util.concurrent.Executor;
2225
import java.util.stream.Collectors;
2326
import java.util.stream.Stream;
2427

28+
import graphql.GraphQLContext;
2529
import reactor.core.publisher.Mono;
2630

2731
import org.springframework.core.CoroutinesUtils;
2832
import org.springframework.core.KotlinDetector;
33+
import org.springframework.graphql.execution.ReactorContextManager;
2934
import org.springframework.lang.Nullable;
35+
import org.springframework.util.Assert;
3036

3137
/**
3238
* Extension of {@link HandlerMethod} that adds support for invoking the
@@ -40,8 +46,24 @@ public abstract class InvocableHandlerMethodSupport extends HandlerMethod {
4046
private static final Object NO_VALUE = new Object();
4147

4248

43-
protected InvocableHandlerMethodSupport(HandlerMethod handlerMethod) {
49+
private final boolean hasCallableReturnValue;
50+
51+
@Nullable
52+
private final Executor executor;
53+
54+
55+
/**
56+
* Create an instance.
57+
* @param handlerMethod the controller method
58+
* @param executor an {@link Executor} to use for {@link Callable} return values
59+
*/
60+
protected InvocableHandlerMethodSupport(HandlerMethod handlerMethod, @Nullable Executor executor) {
4461
super(handlerMethod.createWithResolvedBean());
62+
this.hasCallableReturnValue = getReturnType().getParameterType().equals(Callable.class);
63+
this.executor = executor;
64+
Assert.isTrue(!this.hasCallableReturnValue || this.executor != null,
65+
"Controller method declared with Callable return value, but no Executor configured: " +
66+
handlerMethod.getBridgedMethod().toGenericString());
4567
}
4668

4769

@@ -51,8 +73,9 @@ protected InvocableHandlerMethodSupport(HandlerMethod handlerMethod) {
5173
* @return the value returned from the method or a {@code Mono<Throwable>}
5274
* if the invocation fails.
5375
*/
76+
@SuppressWarnings("ReactiveStreamsUnusedPublisher")
5477
@Nullable
55-
protected Object doInvoke(Object... argValues) {
78+
protected Object doInvoke(GraphQLContext graphQLContext, Object... argValues) {
5679
if (logger.isTraceEnabled()) {
5780
logger.trace("Arguments: " + Arrays.toString(argValues));
5881
}
@@ -61,7 +84,8 @@ protected Object doInvoke(Object... argValues) {
6184
if (KotlinDetector.isSuspendingFunction(method)) {
6285
return CoroutinesUtils.invokeSuspendingFunction(method, getBean(), argValues);
6386
}
64-
return method.invoke(getBean(), argValues);
87+
Object result = method.invoke(getBean(), argValues);
88+
return handleReturnValue(graphQLContext, result);
6589
}
6690
catch (IllegalArgumentException ex) {
6791
assertTargetBean(method, getBean(), argValues);
@@ -84,6 +108,24 @@ protected Object doInvoke(Object... argValues) {
84108
}
85109
}
86110

111+
@Nullable
112+
private Object handleReturnValue(GraphQLContext graphQLContext, @Nullable Object result) {
113+
if (this.hasCallableReturnValue && result != null) {
114+
return CompletableFuture.supplyAsync(
115+
() -> {
116+
try {
117+
return ReactorContextManager.invokeCallable((Callable<?>) result, graphQLContext);
118+
}
119+
catch (Exception ex) {
120+
throw new IllegalStateException(
121+
"Failure in Callable returned from " + getBridgedMethod().toGenericString(), ex);
122+
}
123+
},
124+
this.executor);
125+
}
126+
return result;
127+
}
128+
87129
/**
88130
* Use this method to resolve the arguments asynchronously. This is only
89131
* useful when at least one of the values is a {@link Mono}

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import java.util.LinkedHashSet;
2626
import java.util.Map;
2727
import java.util.Set;
28+
import java.util.concurrent.Callable;
29+
import java.util.concurrent.Executor;
2830
import java.util.stream.Collectors;
2931

3032
import javax.validation.Validator;
@@ -110,6 +112,12 @@ public class AnnotatedControllerConfigurer
110112
"javax.validation.executable.ExecutableValidator",
111113
AnnotatedControllerConfigurer.class.getClassLoader());
112114

115+
116+
private final FormattingConversionService conversionService = new DefaultFormattingConversionService();
117+
118+
@Nullable
119+
private Executor executor;
120+
113121
@Nullable
114122
private ApplicationContext applicationContext;
115123

@@ -119,8 +127,6 @@ public class AnnotatedControllerConfigurer
119127
@Nullable
120128
private HandlerMethodInputValidator validator;
121129

122-
private FormattingConversionService conversionService = new DefaultFormattingConversionService();
123-
124130

125131
/**
126132
* Add a {@code FormatterRegistrar} to customize the {@link ConversionService}
@@ -132,6 +138,17 @@ public void addFormatterRegistrar(FormatterRegistrar registrar) {
132138
registrar.registerFormatters(this.conversionService);
133139
}
134140

141+
/**
142+
* Configure an {@link Executor} to use for asynchronous handling of
143+
* {@link Callable} return values from controller methods.
144+
* <p>By default, this is not set in which case controller methods with a
145+
* {@code Callable} return value cannot be registered.
146+
* @param executor the executor to use
147+
*/
148+
public void setExecutor(Executor executor) {
149+
this.executor = executor;
150+
}
151+
135152
@Override
136153
public void setApplicationContext(ApplicationContext applicationContext) {
137154
this.applicationContext = applicationContext;
@@ -195,7 +212,7 @@ public void configure(RuntimeWiring.Builder runtimeWiringBuilder) {
195212
findHandlerMethods().forEach((info) -> {
196213
DataFetcher<?> dataFetcher;
197214
if (!info.isBatchMapping()) {
198-
dataFetcher = new SchemaMappingDataFetcher(info, this.argumentResolvers, this.validator);
215+
dataFetcher = new SchemaMappingDataFetcher(info, this.argumentResolvers, this.validator, this.executor);
199216
}
200217
else {
201218
String dataLoaderKey = registerBatchLoader(info);
@@ -359,13 +376,16 @@ private String registerBatchLoader(MappingInfo info) {
359376
BatchLoaderRegistry registry = obtainApplicationContext().getBean(BatchLoaderRegistry.class);
360377

361378
HandlerMethod handlerMethod = info.getHandlerMethod();
362-
BatchLoaderHandlerMethod invocable = new BatchLoaderHandlerMethod(handlerMethod);
379+
BatchLoaderHandlerMethod invocable = new BatchLoaderHandlerMethod(handlerMethod, this.executor);
363380

364-
Class<?> clazz = handlerMethod.getReturnType().getParameterType();
365-
if (clazz.equals(Flux.class) || Collection.class.isAssignableFrom(clazz)) {
381+
MethodParameter returnType = handlerMethod.getReturnType();
382+
Class<?> clazz = returnType.getParameterType();
383+
Class<?> nestedClass = (clazz.equals(Callable.class) ? returnType.nested().getNestedParameterType() : clazz);
384+
385+
if (clazz.equals(Flux.class) || Collection.class.isAssignableFrom(nestedClass)) {
366386
registry.forName(dataLoaderKey).registerBatchLoader(invocable::invokeForIterable);
367387
}
368-
else if (clazz.equals(Mono.class) || clazz.equals(Map.class)) {
388+
else if (clazz.equals(Mono.class) || nestedClass.equals(Map.class)) {
369389
registry.forName(dataLoaderKey).registerMappedBatchLoader(invocable::invokeForMap);
370390
}
371391
else {
@@ -425,7 +445,7 @@ public HandlerMethod getHandlerMethod() {
425445

426446
@Override
427447
public String toString() {
428-
return this.coordinates + " -> " + this.handlerMethod.toString();
448+
return this.coordinates + " -> " + this.handlerMethod;
429449
}
430450
}
431451

@@ -442,25 +462,23 @@ static class SchemaMappingDataFetcher implements DataFetcher<Object> {
442462
@Nullable
443463
private final HandlerMethodInputValidator validator;
444464

465+
@Nullable
466+
private final Executor executor;
467+
445468
private final boolean subscription;
446469

447470
public SchemaMappingDataFetcher(
448471
MappingInfo info, HandlerMethodArgumentResolverComposite resolvers,
449-
@Nullable HandlerMethodInputValidator validator) {
472+
@Nullable HandlerMethodInputValidator validator,
473+
@Nullable Executor executor) {
450474

451475
this.info = info;
452476
this.argumentResolvers = resolvers;
453477
this.validator = validator;
478+
this.executor = executor;
454479
this.subscription = this.info.getCoordinates().getTypeName().equalsIgnoreCase("Subscription");
455480
}
456481

457-
/**
458-
* Return the {@link FieldCoordinates} the HandlerMethod is mapped to.
459-
*/
460-
public FieldCoordinates getCoordinates() {
461-
return this.info.getCoordinates();
462-
}
463-
464482
/**
465483
* Return the {@link HandlerMethod} used to fetch data.
466484
*/
@@ -474,7 +492,7 @@ public HandlerMethod getHandlerMethod() {
474492
public Object get(DataFetchingEnvironment environment) throws Exception {
475493

476494
DataFetcherHandlerMethod handlerMethod = new DataFetcherHandlerMethod(
477-
getHandlerMethod(), this.argumentResolvers, this.validator, this.subscription);
495+
getHandlerMethod(), this.argumentResolvers, this.validator, this.executor, this.subscription);
478496

479497
return handlerMethod.invoke(environment);
480498
}

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/BatchLoaderHandlerMethod.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
import java.util.Arrays;
2020
import java.util.Collection;
2121
import java.util.Map;
22+
import java.util.concurrent.CompletableFuture;
23+
import java.util.concurrent.Executor;
24+
import java.util.function.Function;
2225

2326
import graphql.GraphQLContext;
2427
import org.dataloader.BatchLoaderEnvironment;
@@ -50,8 +53,8 @@ public class BatchLoaderHandlerMethod extends InvocableHandlerMethodSupport {
5053
AnnotatedControllerConfigurer.class.getClassLoader());
5154

5255

53-
public BatchLoaderHandlerMethod(HandlerMethod handlerMethod) {
54-
super(handlerMethod);
56+
public BatchLoaderHandlerMethod(HandlerMethod handlerMethod, @Nullable Executor executor) {
57+
super(handlerMethod, executor);
5558
}
5659

5760

@@ -69,11 +72,11 @@ public BatchLoaderHandlerMethod(HandlerMethod handlerMethod) {
6972
public <K, V> Mono<Map<K, V>> invokeForMap(Collection<K> keys, BatchLoaderEnvironment environment) {
7073
Object[] args = getMethodArgumentValues(keys, environment);
7174
if (doesNotHaveAsyncArgs(args)) {
72-
Object result = doInvoke(args);
75+
Object result = doInvoke(environment.getContext(), args);
7376
return toMonoMap(result);
7477
}
7578
return toArgsMono(args).flatMap(argValues -> {
76-
Object result = doInvoke(argValues);
79+
Object result = doInvoke(environment.getContext(), argValues);
7780
return toMonoMap(result);
7881
});
7982
}
@@ -90,11 +93,11 @@ public <K, V> Mono<Map<K, V>> invokeForMap(Collection<K> keys, BatchLoaderEnviro
9093
public <V> Flux<V> invokeForIterable(Collection<?> keys, BatchLoaderEnvironment environment) {
9194
Object[] args = getMethodArgumentValues(keys, environment);
9295
if (doesNotHaveAsyncArgs(args)) {
93-
Object result = doInvoke(args);
96+
Object result = doInvoke(environment.getContext(), args);
9497
return toFlux(result);
9598
}
9699
return toArgsMono(args).flatMapMany(resolvedArgs -> {
97-
Object result = doInvoke(resolvedArgs);
100+
Object result = doInvoke(environment.getContext(), resolvedArgs);
98101
return toFlux(result);
99102
});
100103
}
@@ -165,6 +168,9 @@ private static <K, V> Mono<Map<K, V>> toMonoMap(@Nullable Object result) {
165168
else if (result instanceof Mono) {
166169
return (Mono<Map<K, V>>) result;
167170
}
171+
else if (result instanceof CompletableFuture) {
172+
return Mono.fromFuture((CompletableFuture<? extends Map<K,V>>) result);
173+
}
168174
return Mono.error(new IllegalStateException("Unexpected return value: " + result));
169175
}
170176

@@ -176,6 +182,10 @@ private static <V> Flux<V> toFlux(@Nullable Object result) {
176182
else if (result instanceof Flux) {
177183
return (Flux<V>) result;
178184
}
185+
else if (result instanceof CompletableFuture) {
186+
return Mono.fromFuture((CompletableFuture<? extends Collection<V>>) result)
187+
.flatMapIterable(Function.identity());
188+
}
179189
return Flux.error(new IllegalStateException("Unexpected return value: " + result));
180190
}
181191

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.springframework.graphql.data.method.annotation.support;
1717

1818
import java.util.Arrays;
19+
import java.util.concurrent.Executor;
1920

2021
import graphql.schema.DataFetchingEnvironment;
2122
import org.reactivestreams.Publisher;
@@ -65,9 +66,9 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport {
6566
*/
6667
public DataFetcherHandlerMethod(HandlerMethod handlerMethod,
6768
HandlerMethodArgumentResolverComposite resolvers, @Nullable HandlerMethodInputValidator validator,
68-
boolean subscription) {
69+
@Nullable Executor executor, boolean subscription) {
6970

70-
super(handlerMethod);
71+
super(handlerMethod, executor);
7172
Assert.isTrue(!resolvers.getResolvers().isEmpty(), "No argument resolvers");
7273
this.resolvers = resolvers;
7374
this.validator = validator;
@@ -118,17 +119,17 @@ public Object invoke(DataFetchingEnvironment environment) {
118119
}
119120

120121
if (Arrays.stream(args).noneMatch(arg -> arg instanceof Mono)) {
121-
return validateAndInvoke(args);
122+
return validateAndInvoke(args, environment);
122123
}
123124

124125
return this.subscription ?
125126
toArgsMono(args).flatMapMany(argValues -> {
126-
Object result = validateAndInvoke(argValues);
127+
Object result = validateAndInvoke(argValues, environment);
127128
Assert.state(result instanceof Publisher, "Expected a Publisher from a Subscription response");
128129
return Flux.from((Publisher<?>) result);
129130
}) :
130131
toArgsMono(args).flatMap(argValues -> {
131-
Object result = validateAndInvoke(argValues);
132+
Object result = validateAndInvoke(argValues, environment);
132133
if (result instanceof Mono) {
133134
return (Mono<?>) result;
134135
}
@@ -183,11 +184,11 @@ private Object[] getMethodArgumentValues(
183184
}
184185

185186
@Nullable
186-
private Object validateAndInvoke(Object[] args) {
187+
private Object validateAndInvoke(Object[] args, DataFetchingEnvironment environment) {
187188
if (this.validator != null) {
188189
this.validator.validate(this, args);
189190
}
190-
return doInvoke(args);
191+
return doInvoke(environment.getGraphQlContext(), args);
191192
}
192193

193194
}

spring-graphql/src/main/java/org/springframework/graphql/execution/ContextDataFetcherDecorator.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,11 @@ private ContextDataFetcherDecorator(DataFetcher<?> delegate, boolean subscriptio
5959

6060
@Override
6161
public Object get(DataFetchingEnvironment environment) throws Exception {
62-
ContextView contextView = ReactorContextManager.getReactorContext(environment.getGraphQlContext());
6362

64-
Object value;
65-
try {
66-
ReactorContextManager.restoreThreadLocalValues(contextView);
67-
value = this.delegate.get(environment);
68-
}
69-
finally {
70-
ReactorContextManager.resetThreadLocalValues(contextView);
71-
}
63+
Object value = ReactorContextManager.invokeCallable(() ->
64+
this.delegate.get(environment), environment.getGraphQlContext());
65+
66+
ContextView contextView = ReactorContextManager.getReactorContext(environment.getGraphQlContext());
7267

7368
if (this.subscription) {
7469
return (!contextView.isEmpty() ? Flux.from((Publisher<?>) value).contextWrite(contextView) : value);

0 commit comments

Comments
 (0)