Skip to content

Commit 7e91733

Browse files
committed
Infer HTTP 404 from empty Optional/Publisher types
This commit handles "empty" cases for `ResponseEntity` controller handler return types when wrapped with a `java.util.Optional` in Spring MVC or a single `Publisher` like `Mono`. Given the following example for Spring MVC: ``` @GetMapping("/user") public Optional<ResponseEntity<User>> fetchUser() { Optional<User> user = //... return user.map(ResponseEntity::ok); } ``` If the resulting `Optional` is empty, Spring MVC will infer a `ResponseEntity` with an empty body and a 404 HTTP response status. The same reasoning is applied to Spring WebFlux with Publisher types: ``` @GetMapping("/user") public Mono<ResponseEntity<User>> fetchUser() { Mono<User> user = //... return user.map(ResponseEntity::ok); } ``` This feature is only valid for `HttpEntity` return types and does not apply to `@ResponseBody` controller handlers. Issue: SPR-13281
1 parent 04141de commit 7e91733

File tree

6 files changed

+158
-83
lines changed

6 files changed

+158
-83
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand
5555

5656
private static final Set<HttpMethod> SAFE_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD);
5757

58+
private static final ResponseEntity<Object> notFound = new ResponseEntity<>(HttpStatus.NOT_FOUND);
59+
5860

5961
/**
6062
* Basic constructor with a default {@link ReactiveAdapterRegistry}.
@@ -119,7 +121,8 @@ public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result)
119121

120122
if (adapter != null) {
121123
Assert.isTrue(!adapter.isMultiValue(), "Only a single ResponseEntity supported");
122-
returnValueMono = Mono.from(adapter.toPublisher(result.getReturnValue()));
124+
returnValueMono = Mono.from(adapter.toPublisher(result.getReturnValue()))
125+
.defaultIfEmpty(notFound);
123126
bodyParameter = actualParameter.nested().nested();
124127
}
125128
else {

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,20 @@ public void handleResponseEntityWithExistingResponseHeaders() throws Exception {
350350
assertResponseBodyIsEmpty(exchange);
351351
}
352352

353+
@Test // SPR-13281
354+
public void handleEmptyMonoShouldResultInNotFoundStatus() {
355+
MockServerWebExchange exchange = MockServerWebExchange.from(get("/path"));
356+
exchange.getAttributes().put(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, Collections.singleton(APPLICATION_JSON));
357+
358+
MethodParameter type = on(TestController.class).resolveReturnType(Mono.class, ResponseEntity.class);
359+
HandlerResult result = new HandlerResult(new TestController(), Mono.empty(), type);
360+
361+
this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5));
362+
363+
assertEquals(HttpStatus.NOT_FOUND, exchange.getResponse().getStatusCode());
364+
assertResponseBodyIsEmpty(exchange);
365+
}
366+
353367

354368

355369
private void testHandle(Object returnValue, MethodParameter returnType) {

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.EnumSet;
2525
import java.util.List;
2626
import java.util.Map;
27+
import java.util.Optional;
2728
import java.util.Set;
2829
import javax.servlet.http.HttpServletRequest;
2930
import javax.servlet.http.HttpServletResponse;
@@ -33,6 +34,7 @@
3334
import org.springframework.http.HttpEntity;
3435
import org.springframework.http.HttpHeaders;
3536
import org.springframework.http.HttpMethod;
37+
import org.springframework.http.HttpStatus;
3638
import org.springframework.http.RequestEntity;
3739
import org.springframework.http.ResponseEntity;
3840
import org.springframework.http.converter.HttpMessageConverter;
@@ -121,8 +123,9 @@ public boolean supportsParameter(MethodParameter parameter) {
121123

122124
@Override
123125
public boolean supportsReturnType(MethodParameter returnType) {
124-
return (HttpEntity.class.isAssignableFrom(returnType.getParameterType()) &&
125-
!RequestEntity.class.isAssignableFrom(returnType.getParameterType()));
126+
Class<?> parameterType = returnType.nestedIfOptional().getNestedParameterType();
127+
return (HttpEntity.class.isAssignableFrom(parameterType) &&
128+
!RequestEntity.class.isAssignableFrom(parameterType));
126129
}
127130

128131
@Override
@@ -150,7 +153,7 @@ public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewC
150153

151154
@Nullable
152155
private Type getHttpEntityType(MethodParameter parameter) {
153-
Assert.isAssignable(HttpEntity.class, parameter.getParameterType());
156+
Assert.isAssignable(HttpEntity.class, parameter.getNestedParameterType());
154157
Type parameterType = parameter.getGenericParameterType();
155158
if (parameterType instanceof ParameterizedType) {
156159
ParameterizedType type = (ParameterizedType) parameterType;
@@ -169,6 +172,7 @@ else if (parameterType instanceof Class) {
169172
}
170173

171174
@Override
175+
@SuppressWarnings("unchecked")
172176
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
173177
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
174178

@@ -180,6 +184,12 @@ public void handleReturnValue(@Nullable Object returnValue, MethodParameter retu
180184
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
181185
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
182186

187+
if (returnType.getParameterType() == Optional.class) {
188+
Optional<HttpEntity<?>> optionalEntity = (Optional<HttpEntity<?>>) returnValue;
189+
returnValue = optionalEntity.orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND));
190+
returnType = returnType.nestedIfOptional();
191+
}
192+
183193
Assert.isInstanceOf(HttpEntity.class, returnValue);
184194
HttpEntity<?> responseEntity = (HttpEntity<?>) returnValue;
185195

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java

Lines changed: 91 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import java.io.ByteArrayInputStream;
2020
import java.io.IOException;
2121
import java.io.InputStream;
22-
import java.lang.reflect.Method;
2322
import java.net.URI;
2423
import java.nio.charset.StandardCharsets;
2524
import java.time.ZoneId;
@@ -28,6 +27,7 @@
2827
import java.util.Arrays;
2928
import java.util.Collections;
3029
import java.util.Date;
30+
import java.util.Optional;
3131

3232
import org.hamcrest.Matchers;
3333
import org.junit.Before;
@@ -56,14 +56,17 @@
5656
import org.springframework.web.HttpMediaTypeNotSupportedException;
5757
import org.springframework.web.bind.annotation.RequestMapping;
5858
import org.springframework.web.context.request.ServletWebRequest;
59+
import org.springframework.web.method.ResolvableMethod;
5960
import org.springframework.web.method.support.ModelAndViewContainer;
6061

61-
import static java.time.Instant.*;
62-
import static java.time.format.DateTimeFormatter.*;
62+
import static java.time.Instant.ofEpochMilli;
63+
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
6364
import static org.junit.Assert.*;
6465
import static org.mockito.BDDMockito.*;
65-
import static org.springframework.http.MediaType.*;
66-
import static org.springframework.web.servlet.HandlerMapping.*;
66+
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
67+
import static org.springframework.http.MediaType.TEXT_PLAIN;
68+
import static org.springframework.web.method.ResolvableMethod.on;
69+
import static org.springframework.web.servlet.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
6770

6871
/**
6972
* Test fixture for {@link HttpEntityMethodProcessor} delegating to a mock
@@ -91,26 +94,6 @@ public class HttpEntityMethodProcessorMockTests {
9194

9295
private HttpMessageConverter<Object> resourceRegionMessageConverter;
9396

94-
private MethodParameter paramHttpEntity;
95-
96-
private MethodParameter paramRequestEntity;
97-
98-
private MethodParameter paramResponseEntity;
99-
100-
private MethodParameter paramInt;
101-
102-
private MethodParameter returnTypeResponseEntity;
103-
104-
private MethodParameter returnTypeResponseEntityProduces;
105-
106-
private MethodParameter returnTypeResponseEntityResource;
107-
108-
private MethodParameter returnTypeHttpEntity;
109-
110-
private MethodParameter returnTypeHttpEntitySubclass;
111-
112-
private MethodParameter returnTypeInt;
113-
11497
private ModelAndViewContainer mavContainer;
11598

11699
private MockHttpServletRequest servletRequest;
@@ -119,6 +102,12 @@ public class HttpEntityMethodProcessorMockTests {
119102

120103
private ServletWebRequest webRequest;
121104

105+
private MethodParameter returnTypeResponseEntity =
106+
on(TestHandler.class).resolveReturnType(ResponseEntity.class, String.class);
107+
108+
private MethodParameter returnTypeResponseEntityResource =
109+
on(TestHandler.class).resolveReturnType(ResponseEntity.class, Resource.class);
110+
122111

123112
@Before
124113
@SuppressWarnings("unchecked")
@@ -139,20 +128,6 @@ public void setup() throws Exception {
139128
processor = new HttpEntityMethodProcessor(Arrays.asList(
140129
stringHttpMessageConverter, resourceMessageConverter, resourceRegionMessageConverter));
141130

142-
Method handle1 = getClass().getMethod("handle1", HttpEntity.class, ResponseEntity.class,
143-
Integer.TYPE, RequestEntity.class);
144-
145-
paramHttpEntity = new MethodParameter(handle1, 0);
146-
paramRequestEntity = new MethodParameter(handle1, 3);
147-
paramResponseEntity = new MethodParameter(handle1, 1);
148-
paramInt = new MethodParameter(handle1, 2);
149-
returnTypeResponseEntity = new MethodParameter(handle1, -1);
150-
returnTypeResponseEntityProduces = new MethodParameter(getClass().getMethod("handle4"), -1);
151-
returnTypeHttpEntity = new MethodParameter(getClass().getMethod("handle2", HttpEntity.class), -1);
152-
returnTypeHttpEntitySubclass = new MethodParameter(getClass().getMethod("handle2x", HttpEntity.class), -1);
153-
returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1);
154-
returnTypeResponseEntityResource = new MethodParameter(getClass().getMethod("handle5"), -1);
155-
156131
mavContainer = new ModelAndViewContainer();
157132
servletRequest = new MockHttpServletRequest("GET", "/foo");
158133
servletResponse = new MockHttpServletResponse();
@@ -162,25 +137,39 @@ public void setup() throws Exception {
162137

163138
@Test
164139
public void supportsParameter() {
165-
assertTrue("HttpEntity parameter not supported", processor.supportsParameter(paramHttpEntity));
166-
assertTrue("RequestEntity parameter not supported", processor.supportsParameter(paramRequestEntity));
167-
assertFalse("ResponseEntity parameter supported", processor.supportsParameter(paramResponseEntity));
168-
assertFalse("non-entity parameter supported", processor.supportsParameter(paramInt));
140+
ResolvableMethod method = on(TestHandler.class).named("entityParams").build();
141+
assertTrue("HttpEntity parameter not supported",
142+
processor.supportsParameter(method.arg(HttpEntity.class, String.class)));
143+
assertTrue("RequestEntity parameter not supported",
144+
processor.supportsParameter(method.arg(RequestEntity.class, String.class)));
145+
assertFalse("ResponseEntity parameter supported",
146+
processor.supportsParameter(method.arg(ResponseEntity.class, String.class)));
147+
assertFalse("non-entity parameter supported",
148+
processor.supportsParameter(method.arg(Integer.class)));
169149
}
170150

171151
@Test
172152
public void supportsReturnType() {
173-
assertTrue("ResponseEntity return type not supported", processor.supportsReturnType(returnTypeResponseEntity));
174-
assertTrue("HttpEntity return type not supported", processor.supportsReturnType(returnTypeHttpEntity));
175-
assertTrue("Custom HttpEntity subclass not supported", processor.supportsReturnType(returnTypeHttpEntitySubclass));
176-
assertFalse("RequestEntity parameter supported",
177-
processor.supportsReturnType(paramRequestEntity));
178-
assertFalse("non-ResponseBody return type supported", processor.supportsReturnType(returnTypeInt));
153+
assertTrue("ResponseEntity return type not supported",
154+
processor.supportsReturnType(on(TestHandler.class).resolveReturnType(ResponseEntity.class, String.class)));
155+
assertTrue("HttpEntity return type not supported",
156+
processor.supportsReturnType(on(TestHandler.class).resolveReturnType(HttpEntity.class)));
157+
assertTrue("Custom HttpEntity subclass not supported",
158+
processor.supportsReturnType(on(TestHandler.class).resolveReturnType(CustomHttpEntity.class)));
159+
assertTrue("Optional ResponseEntity not supported",
160+
processor.supportsReturnType(on(TestHandler.class).resolveReturnType(Optional.class, ResponseEntity.class)));
161+
assertFalse("RequestEntity return type supported",
162+
processor.supportsReturnType(on(TestHandler.class)
163+
.named("entityParams").build().arg(RequestEntity.class, String.class)));
164+
assertFalse("non-ResponseBody return type supported",
165+
processor.supportsReturnType(on(TestHandler.class).resolveReturnType(Integer.class)));
179166
}
180167

181168
@Test
182169
public void shouldResolveHttpEntityArgument() throws Exception {
183170
String body = "Foo";
171+
MethodParameter paramHttpEntity = on(TestHandler.class).named("entityParams")
172+
.build().arg(HttpEntity.class, String.class);
184173

185174
MediaType contentType = TEXT_PLAIN;
186175
servletRequest.addHeader("Content-Type", contentType.toString());
@@ -199,6 +188,8 @@ public void shouldResolveHttpEntityArgument() throws Exception {
199188
@Test
200189
public void shouldResolveRequestEntityArgument() throws Exception {
201190
String body = "Foo";
191+
MethodParameter paramRequestEntity = on(TestHandler.class).named("entityParams")
192+
.build().arg(RequestEntity.class, String.class);
202193

203194
MediaType contentType = TEXT_PLAIN;
204195
servletRequest.addHeader("Content-Type", contentType.toString());
@@ -225,6 +216,8 @@ public void shouldResolveRequestEntityArgument() throws Exception {
225216

226217
@Test
227218
public void shouldFailResolvingWhenConverterCannotRead() throws Exception {
219+
MethodParameter paramHttpEntity = on(TestHandler.class).named("entityParams")
220+
.build().arg(HttpEntity.class, String.class);
228221
MediaType contentType = TEXT_PLAIN;
229222
servletRequest.setMethod("POST");
230223
servletRequest.addHeader("Content-Type", contentType.toString());
@@ -238,6 +231,8 @@ public void shouldFailResolvingWhenConverterCannotRead() throws Exception {
238231

239232
@Test
240233
public void shouldFailResolvingWhenContentTypeNotSupported() throws Exception {
234+
MethodParameter paramHttpEntity = on(TestHandler.class).named("entityParams")
235+
.build().arg(HttpEntity.class, String.class);
241236
servletRequest.setMethod("POST");
242237
servletRequest.setContent("some content".getBytes(StandardCharsets.UTF_8));
243238
this.thrown.expect(HttpMediaTypeNotSupportedException.class);
@@ -260,13 +255,14 @@ public void shouldHandleReturnValue() throws Exception {
260255

261256
@Test
262257
public void shouldHandleReturnValueWithProducibleMediaType() throws Exception {
258+
MethodParameter returnType = on(TestHandler.class).resolveReturnType(ResponseEntity.class, CharSequence.class);
263259
String body = "Foo";
264260
ResponseEntity<String> returnValue = new ResponseEntity<>(body, HttpStatus.OK);
265261
servletRequest.addHeader("Accept", "text/*");
266262
servletRequest.setAttribute(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, Collections.singleton(MediaType.TEXT_HTML));
267263
given(stringHttpMessageConverter.canWrite(String.class, MediaType.TEXT_HTML)).willReturn(true);
268264

269-
processor.handleReturnValue(returnValue, returnTypeResponseEntityProduces, mavContainer, webRequest);
265+
processor.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
270266

271267
assertTrue(mavContainer.isRequestHandled());
272268
verify(stringHttpMessageConverter).write(eq(body), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class));
@@ -311,6 +307,7 @@ public void shouldFailHandlingWhenContentTypeNotSupported() throws Exception {
311307

312308
@Test
313309
public void shouldFailHandlingWhenConverterCannotWrite() throws Exception {
310+
MethodParameter returnType = on(TestHandler.class).resolveReturnType(ResponseEntity.class, CharSequence.class);
314311
String body = "Foo";
315312
ResponseEntity<String> returnValue = new ResponseEntity<>(body, HttpStatus.OK);
316313
MediaType accepted = TEXT_PLAIN;
@@ -322,7 +319,7 @@ public void shouldFailHandlingWhenConverterCannotWrite() throws Exception {
322319
given(stringHttpMessageConverter.canWrite(String.class, accepted)).willReturn(false);
323320

324321
this.thrown.expect(HttpMediaTypeNotAcceptableException.class);
325-
processor.handleReturnValue(returnValue, returnTypeResponseEntityProduces, mavContainer, webRequest);
322+
processor.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
326323
}
327324

328325
@Test // SPR-9142
@@ -361,6 +358,16 @@ public void shouldHandleResponseHeaderAndBody() throws Exception {
361358
assertEquals("headerValue", outputMessage.getValue().getHeaders().get("header").get(0));
362359
}
363360

361+
@Test // SPR-13281
362+
public void shouldReturnNotFoundWhenEmptyOptional() throws Exception {
363+
MethodParameter returnType = on(TestHandler.class).resolveReturnType(Optional.class, ResponseEntity.class);
364+
Optional<ResponseEntity> returnValue = Optional.empty();
365+
366+
processor.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
367+
assertTrue(mavContainer.isRequestHandled());
368+
assertEquals(HttpStatus.NOT_FOUND.value(), servletResponse.getStatus());
369+
}
370+
364371
@Test
365372
public void shouldHandleLastModifiedWithHttp304() throws Exception {
366373
long currentTime = new Date().getTime();
@@ -697,38 +704,46 @@ private void assertConditionalResponse(HttpStatus status, String body, String et
697704
}
698705
}
699706

707+
private static class TestHandler {
700708

701-
@SuppressWarnings("unused")
702-
public ResponseEntity<String> handle1(HttpEntity<String> httpEntity, ResponseEntity<String> entity,
703-
int i, RequestEntity<String> requestEntity) {
709+
@SuppressWarnings("unused")
710+
public ResponseEntity<String> entityParams(HttpEntity<String> httpEntity, ResponseEntity<String> entity,
711+
Integer i, RequestEntity<String> requestEntity) {
704712

705-
return entity;
706-
}
713+
return entity;
714+
}
707715

708-
@SuppressWarnings("unused")
709-
public HttpEntity<?> handle2(HttpEntity<?> entity) {
710-
return entity;
711-
}
716+
@SuppressWarnings("unused")
717+
public HttpEntity<?> httpEntity(HttpEntity<?> entity) {
718+
return entity;
719+
}
712720

713-
@SuppressWarnings("unused")
714-
public CustomHttpEntity handle2x(HttpEntity<?> entity) {
715-
return new CustomHttpEntity();
716-
}
721+
@SuppressWarnings("unused")
722+
public CustomHttpEntity customHttpEntity(HttpEntity<?> entity) {
723+
return new CustomHttpEntity();
724+
}
717725

718-
@SuppressWarnings("unused")
719-
public int handle3() {
720-
return 42;
721-
}
726+
@SuppressWarnings("unused")
727+
public Optional<ResponseEntity> handleOptionalEntity() {
728+
return null;
729+
}
722730

723-
@SuppressWarnings("unused")
724-
@RequestMapping(produces = {"text/html", "application/xhtml+xml"})
725-
public ResponseEntity<String> handle4() {
726-
return null;
727-
}
731+
@SuppressWarnings("unused")
732+
@RequestMapping(produces = {"text/html", "application/xhtml+xml"})
733+
public ResponseEntity<CharSequence> produces() {
734+
return null;
735+
}
736+
737+
@SuppressWarnings("unused")
738+
public ResponseEntity<Resource> resourceResponseEntity() {
739+
return null;
740+
}
741+
742+
@SuppressWarnings("unused")
743+
public Integer integer() {
744+
return 42;
745+
}
728746

729-
@SuppressWarnings("unused")
730-
public ResponseEntity<Resource> handle5() {
731-
return null;
732747
}
733748

734749
@SuppressWarnings("unused")

0 commit comments

Comments
 (0)