Skip to content

Commit d8a7b96

Browse files
committed
WebFlux support for "request handled" in controller
Issue: SPR-16087
1 parent a3eeda9 commit d8a7b96

File tree

5 files changed

+205
-13
lines changed

5 files changed

+205
-13
lines changed

spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import java.lang.reflect.InvocationTargetException;
2020
import java.lang.reflect.Method;
21+
import java.lang.reflect.ParameterizedType;
22+
import java.lang.reflect.Type;
2123
import java.util.ArrayList;
2224
import java.util.Arrays;
2325
import java.util.List;
@@ -31,7 +33,10 @@
3133
import org.springframework.core.DefaultParameterNameDiscoverer;
3234
import org.springframework.core.MethodParameter;
3335
import org.springframework.core.ParameterNameDiscoverer;
36+
import org.springframework.core.ReactiveAdapter;
37+
import org.springframework.core.ReactiveAdapterRegistry;
3438
import org.springframework.http.HttpStatus;
39+
import org.springframework.http.server.reactive.ServerHttpResponse;
3540
import org.springframework.lang.Nullable;
3641
import org.springframework.util.ClassUtils;
3742
import org.springframework.util.ObjectUtils;
@@ -61,6 +66,8 @@ public class InvocableHandlerMethod extends HandlerMethod {
6166

6267
private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
6368

69+
private ReactiveAdapterRegistry reactiveAdapterRegistry = new ReactiveAdapterRegistry();
70+
6471

6572
public InvocableHandlerMethod(HandlerMethod handlerMethod) {
6673
super(handlerMethod);
@@ -103,6 +110,18 @@ public ParameterNameDiscoverer getParameterNameDiscoverer() {
103110
return this.parameterNameDiscoverer;
104111
}
105112

113+
/**
114+
* Configure a reactive registry. This is needed for cases where the response
115+
* is fully handled within the controller in combination with an async void
116+
* return value.
117+
* <p>By default this is an instance of {@link ReactiveAdapterRegistry} with
118+
* default settings.
119+
* @param registry the registry to use
120+
*/
121+
public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) {
122+
this.reactiveAdapterRegistry = registry;
123+
}
124+
106125

107126
/**
108127
* Invoke the method for the given exchange.
@@ -117,11 +136,21 @@ public Mono<HandlerResult> invoke(ServerWebExchange exchange, BindingContext bin
117136
return resolveArguments(exchange, bindingContext, providedArgs).flatMap(args -> {
118137
try {
119138
Object value = doInvoke(args);
120-
HandlerResult result = new HandlerResult(this, value, getReturnType(), bindingContext);
139+
121140
HttpStatus status = getResponseStatus();
122141
if (status != null) {
123142
exchange.getResponse().setStatusCode(status);
124143
}
144+
145+
MethodParameter returnType = getReturnType();
146+
ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(returnType.getParameterType());
147+
boolean asyncVoid = isAsyncVoidReturnType(returnType, adapter);
148+
if ((value == null || asyncVoid) && isResponseHandled(args, exchange)) {
149+
logger.debug("Response fully handled in controller method");
150+
return asyncVoid ? Mono.from(adapter.toPublisher(value)) : Mono.empty();
151+
}
152+
153+
HandlerResult result = new HandlerResult(this, value, returnType, bindingContext);
125154
return Mono.just(result);
126155
}
127156
catch (InvocationTargetException ex) {
@@ -204,6 +233,7 @@ private String getDetailedErrorMessage(String text, MethodParameter param) {
204233
param.getParameterType().getName() + "' on " + getBridgedMethod().toGenericString();
205234
}
206235

236+
@Nullable
207237
private Object doInvoke(Object[] args) throws Exception {
208238
if (logger.isTraceEnabled()) {
209239
logger.trace("Invoking '" + ClassUtils.getQualifiedMethodName(getMethod(), getBeanType()) +
@@ -228,4 +258,34 @@ private String getInvocationErrorMessage(Object[] args) {
228258
"on " + getBridgedMethod().toGenericString();
229259
}
230260

261+
private boolean isAsyncVoidReturnType(MethodParameter returnType,
262+
@Nullable ReactiveAdapter reactiveAdapter) {
263+
264+
if (reactiveAdapter != null && reactiveAdapter.supportsEmpty()) {
265+
if (reactiveAdapter.isNoValue()) {
266+
return true;
267+
}
268+
Type parameterType = returnType.getGenericParameterType();
269+
if (parameterType instanceof ParameterizedType) {
270+
ParameterizedType type = (ParameterizedType) parameterType;
271+
if (type.getActualTypeArguments().length == 1) {
272+
return Void.class.equals(type.getActualTypeArguments()[0]);
273+
}
274+
}
275+
}
276+
return false;
277+
}
278+
279+
private boolean isResponseHandled(Object[] args, ServerWebExchange exchange) {
280+
if (getResponseStatus() != null || exchange.isNotModified()) {
281+
return true;
282+
}
283+
for (Object arg : args) {
284+
if (arg instanceof ServerHttpResponse || arg instanceof ServerWebExchange) {
285+
return true;
286+
}
287+
}
288+
return false;
289+
}
290+
231291
}

spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class ControllerMethodResolver {
8282

8383
private final List<HandlerMethodArgumentResolver> exceptionHandlerResolvers;
8484

85+
private final ReactiveAdapterRegistry reactiveAdapterRegistry;
86+
8587

8688
private final Map<Class<?>, Set<Method>> initBinderMethodCache = new ConcurrentHashMap<>(64);
8789

@@ -127,6 +129,8 @@ class ControllerMethodResolver {
127129
addResolversTo(registrar, reactiveRegistry, context);
128130
this.exceptionHandlerResolvers = registrar.getResolvers();
129131

132+
this.reactiveAdapterRegistry = reactiveRegistry;
133+
130134
initControllerAdviceCaches(context);
131135
}
132136

@@ -214,6 +218,7 @@ private void initControllerAdviceCaches(@Nullable ApplicationContext application
214218
public InvocableHandlerMethod getRequestMappingMethod(HandlerMethod handlerMethod) {
215219
InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod);
216220
invocable.setArgumentResolvers(this.requestMappingResolvers);
221+
invocable.setReactiveAdapterRegistry(this.reactiveAdapterRegistry);
217222
return invocable;
218223
}
219224

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/InvocableHandlerMethodTests.java

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,40 @@
1616

1717
package org.springframework.web.reactive.result.method;
1818

19+
import java.io.UnsupportedEncodingException;
1920
import java.lang.reflect.Method;
21+
import java.time.Duration;
22+
import java.time.Instant;
2023
import java.util.Arrays;
2124

2225
import org.junit.Test;
26+
import reactor.core.publisher.Flux;
2327
import reactor.core.publisher.Mono;
2428
import reactor.test.StepVerifier;
2529

30+
import org.springframework.core.io.buffer.DataBuffer;
31+
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
2632
import org.springframework.http.HttpStatus;
33+
import org.springframework.http.server.reactive.ServerHttpResponse;
34+
import org.springframework.lang.Nullable;
2735
import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest;
2836
import org.springframework.mock.web.test.server.MockServerWebExchange;
2937
import org.springframework.web.bind.annotation.ResponseStatus;
3038
import org.springframework.web.reactive.BindingContext;
3139
import org.springframework.web.reactive.HandlerResult;
40+
import org.springframework.web.server.ServerWebExchange;
3241
import org.springframework.web.server.UnsupportedMediaTypeStatusException;
3342

34-
import static org.hamcrest.Matchers.*;
35-
import static org.junit.Assert.*;
43+
import static org.hamcrest.Matchers.is;
44+
import static org.junit.Assert.assertEquals;
45+
import static org.junit.Assert.assertNull;
46+
import static org.junit.Assert.assertThat;
47+
import static org.junit.Assert.fail;
3648
import static org.mockito.Mockito.any;
37-
import static org.mockito.Mockito.*;
38-
import static org.springframework.web.method.ResolvableMethod.*;
49+
import static org.mockito.Mockito.mock;
50+
import static org.mockito.Mockito.when;
51+
import static org.springframework.mock.http.server.reactive.test.MockServerHttpRequest.get;
52+
import static org.springframework.web.method.ResolvableMethod.on;
3953

4054
/**
4155
* Unit tests for {@link InvocableHandlerMethod}.
@@ -45,10 +59,72 @@
4559
*/
4660
public class InvocableHandlerMethodTests {
4761

48-
private final MockServerWebExchange exchange =
49-
MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost:8080/path"));
62+
private final MockServerWebExchange exchange = MockServerWebExchange.from(get("http://localhost:8080/path"));
5063

5164

65+
@Test
66+
public void invokeAndHandle_VoidWithResponseStatus() throws Exception {
67+
Method method = on(VoidController.class).mockCall(VoidController::responseStatus).method();
68+
HandlerResult result = invoke(new VoidController(), method).block(Duration.ZERO);
69+
70+
assertNull("Expected no result (i.e. fully handled)", result);
71+
assertEquals(HttpStatus.BAD_REQUEST, this.exchange.getResponse().getStatusCode());
72+
}
73+
74+
@Test
75+
public void invokeAndHandle_withResponse() throws Exception {
76+
ServerHttpResponse response = this.exchange.getResponse();
77+
Method method = on(VoidController.class).mockCall(c -> c.response(response)).method();
78+
HandlerResult result = invoke(new VoidController(), method, resolverFor(Mono.just(response)))
79+
.block(Duration.ZERO);
80+
81+
assertNull("Expected no result (i.e. fully handled)", result);
82+
assertEquals("bar", this.exchange.getResponse().getHeaders().getFirst("foo"));
83+
}
84+
85+
@Test
86+
public void invokeAndHandle_withResponseAndMonoVoid() throws Exception {
87+
ServerHttpResponse response = this.exchange.getResponse();
88+
Method method = on(VoidController.class).mockCall(c -> c.responseMonoVoid(response)).method();
89+
HandlerResult result = invoke(new VoidController(), method, resolverFor(Mono.just(response)))
90+
.block(Duration.ZERO);
91+
92+
assertNull("Expected no result (i.e. fully handled)", result);
93+
assertEquals("body", this.exchange.getResponse().getBodyAsString().block(Duration.ZERO));
94+
}
95+
96+
@Test
97+
public void invokeAndHandle_withExchange() throws Exception {
98+
Method method = on(VoidController.class).mockCall(c -> c.exchange(exchange)).method();
99+
HandlerResult result = invoke(new VoidController(), method, resolverFor(Mono.just(this.exchange)))
100+
.block(Duration.ZERO);
101+
102+
assertNull("Expected no result (i.e. fully handled)", result);
103+
assertEquals("bar", this.exchange.getResponse().getHeaders().getFirst("foo"));
104+
}
105+
106+
@Test
107+
public void invokeAndHandle_withExchangeAndMonoVoid() throws Exception {
108+
Method method = on(VoidController.class).mockCall(c -> c.exchangeMonoVoid(exchange)).method();
109+
HandlerResult result = invoke(new VoidController(), method, resolverFor(Mono.just(this.exchange)))
110+
.block(Duration.ZERO);
111+
112+
assertNull("Expected no result (i.e. fully handled)", result);
113+
assertEquals("body", this.exchange.getResponse().getBodyAsString().block(Duration.ZERO));
114+
}
115+
116+
@Test
117+
public void invokeAndHandle_withNotModified() throws Exception {
118+
ServerWebExchange exchange = MockServerWebExchange.from(
119+
MockServerHttpRequest.get("/").ifModifiedSince(10 * 1000 * 1000));
120+
121+
Method method = on(VoidController.class).mockCall(c -> c.notModified(exchange)).method();
122+
HandlerResult result = invoke(new VoidController(), method, resolverFor(Mono.just(exchange)))
123+
.block(Duration.ZERO);
124+
125+
assertNull("Expected no result (i.e. fully handled)", result);
126+
}
127+
52128
@Test
53129
public void invokeMethodWithNoArguments() throws Exception {
54130
Method method = on(TestController.class).mockCall(TestController::noArgs).method();
@@ -146,7 +222,7 @@ public void invokeMethodWithResponseStatus() throws Exception {
146222

147223

148224
private Mono<HandlerResult> invoke(Object handler, Method method) {
149-
return this.invoke(handler, method, new HandlerMethodArgumentResolver[0]);
225+
return invoke(handler, method, new HandlerMethodArgumentResolver[0]);
150226
}
151227

152228
private Mono<HandlerResult> invoke(Object handler, Method method,
@@ -195,4 +271,45 @@ public String responseStatus() {
195271
}
196272
}
197273

274+
@SuppressWarnings("unused")
275+
private static class VoidController {
276+
277+
@ResponseStatus(HttpStatus.BAD_REQUEST)
278+
public void responseStatus() {
279+
}
280+
281+
public void response(ServerHttpResponse response) {
282+
response.getHeaders().add("foo", "bar");
283+
}
284+
285+
public Mono<Void> responseMonoVoid(ServerHttpResponse response) {
286+
return response.writeWith(getBody("body"));
287+
}
288+
289+
public void exchange(ServerWebExchange exchange) {
290+
exchange.getResponse().getHeaders().add("foo", "bar");
291+
}
292+
293+
public Mono<Void> exchangeMonoVoid(ServerWebExchange exchange) {
294+
return exchange.getResponse().writeWith(getBody("body"));
295+
}
296+
297+
@Nullable
298+
public String notModified(ServerWebExchange exchange) {
299+
if (exchange.checkNotModified(Instant.ofEpochMilli(1000 * 1000))) {
300+
return null;
301+
}
302+
return "body";
303+
}
304+
305+
private Flux<DataBuffer> getBody(String body) {
306+
try {
307+
return Flux.just(new DefaultDataBufferFactory().wrap(body.getBytes("UTF-8")));
308+
}
309+
catch (UnsupportedEncodingException ex) {
310+
throw new IllegalStateException(ex);
311+
}
312+
}
313+
}
314+
198315
}

src/docs/asciidoc/web/webflux.adoc

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,8 +1029,14 @@ from the request path.
10291029
|An API for model and view rendering scenarios.
10301030

10311031
|`void`
1032-
|For use in method that don't write the response body; or methods where the view name is
1033-
supposed to be determined implicitly from the request path.
1032+
|A method with a `void`, possibly async (e.g. `Mono<Void>`), return type (or a `null` return
1033+
value) is considered to have fully handled the response if it also has a `ServerHttpResponse`,
1034+
or a `ServerWebExchange` argument, or an `@ResponseStatus` annotation. The same is true also
1035+
if the controller has made a positive ETag or lastModified timestamp check.
1036+
// TODO (see <<webflux-caching-etag-lastmodified>> for details)
1037+
1038+
If none of the above is true, a `void` return type may also indicate "no response body" for
1039+
REST controllers, or default view name selection for HTML controllers.
10341040

10351041
|`Flux<ServerSentEvent>`, `Observable<ServerSentEvent>`, or other reactive type
10361042
|Emit server-sent events; the `SeverSentEvent` wrapper can be omitted when only data needs

src/docs/asciidoc/web/webmvc.adoc

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,9 +1684,13 @@ through a `RequestToViewNameTranslator`.
16841684
|The view and model attributes to use, and optionally a response status.
16851685

16861686
|`void`
1687-
|For use in methods that declare a `ServletResponse` or `OutputStream` argument and write
1688-
to the response body; or if the view name is supposed to be implicitly determined through a
1689-
`RequestToViewNameTranslator`.
1687+
|A method with a `void` return type (or `null` return value) is considered to have fully
1688+
handled the response if it also has a `ServletResponse`, or an `OutputStream` argument, or an
1689+
`@ResponseStatus` annotation. The same is true also if the controller has made a positive
1690+
ETag or lastModified timestamp check (see <<mvc-caching-etag-lastmodified>> for details).
1691+
1692+
If none of the above is true, a `void` return type may also indicate "no response body" for
1693+
REST controllers, or default view name selection for HTML controllers.
16901694

16911695
|`Callable<V>`
16921696
|Produce any of the above return values asynchronously in a Spring MVC managed thread.

0 commit comments

Comments
 (0)