Add support for Server-Sent Events

This commit adds ResponseBodyEmitter and SseEmitter (and also
ResponseEntity<ResponseBodyEmitter> and ResponseEntity<SseEmitter>) as
new return value types supported on @RequestMapping controller methods.

See Javadoc on respective types for more details.

Issue: SPR-12212
This commit is contained in:
Rossen Stoyanchev 2015-01-08 11:34:41 -05:00
parent ccb1c13951
commit a32b5e61d0
11 changed files with 1189 additions and 2 deletions

View File

@ -213,6 +213,12 @@ import java.util.concurrent.Callable;
* <li>A {@link org.springframework.util.concurrent.ListenableFuture} * <li>A {@link org.springframework.util.concurrent.ListenableFuture}
* which the application uses to produce a return value in a separate * which the application uses to produce a return value in a separate
* thread of its own choosing, as an alternative to returning a Callable. * thread of its own choosing, as an alternative to returning a Callable.
* <li>A {@link org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter}
* can be used to write multiple objects to the response asynchronously;
* also supported as the body within {@code ResponseEntity}.</li>
* <li>An {@link org.springframework.web.servlet.mvc.method.annotation.SseEmitter}
* can be used to write Server-Sent Events to the response asynchronously;
* also supported as the body within {@code ResponseEntity}.</li>
* <li>{@code void} if the method handles the response itself (by * <li>{@code void} if the method handles the response itself (by
* writing the response content directly, declaring an argument of type * writing the response content directly, declaring an argument of type
* {@link javax.servlet.ServletResponse} / {@link javax.servlet.http.HttpServletResponse} * {@link javax.servlet.ServletResponse} / {@link javax.servlet.http.HttpServletResponse}

View File

@ -632,6 +632,7 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
handlers.add(new ModelAndViewMethodReturnValueHandler()); handlers.add(new ModelAndViewMethodReturnValueHandler());
handlers.add(new ModelMethodProcessor()); handlers.add(new ModelMethodProcessor());
handlers.add(new ViewMethodReturnValueHandler()); handlers.add(new ViewMethodReturnValueHandler());
handlers.add(new ResponseBodyEmitterReturnValueHandler(getMessageConverters()));
handlers.add(new HttpEntityMethodProcessor( handlers.add(new HttpEntityMethodProcessor(
getMessageConverters(), this.contentNegotiationManager, this.responseBodyAdvice)); getMessageConverters(), this.contentNegotiationManager, this.responseBodyAdvice));
handlers.add(new HttpHeadersReturnValueHandler()); handlers.add(new HttpHeadersReturnValueHandler());

View File

@ -0,0 +1,198 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.util.Assert;
/**
* A controller method return value type for asynchronous request processing
* where one or more objects are written to the response. While
* {@link org.springframework.web.context.request.async.DeferredResult DeferredResult}
* is used to produce a single result, a {@code ResponseBodyEmitter} can be used
* to send multiple objects where each object is written with a compatible
* {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter}.
*
* <p>Supported as a return type on its own as well as within a
* {@link org.springframework.http.ResponseEntity ResponseEntity}.
*
* <pre>
* &#064;RequestMapping(value="/stream", method=RequestMethod.GET)
* public ResponseBodyEmitter handle() {
* ResponseBodyEmitter emitter = new ResponseBodyEmitter();
* // Pass the emitter to another component...
* return emitter;
* }
*
* // in another thread
* emitter.send(foo1);
*
* // and again
* emitter.send(foo2);
*
* // and done
* emitter.complete();
* </pre>
*
* <p><strong>Note:</strong> this class is not thread-safe. Callers must ensure
* that use from multiple threads is synchronized.
*
* @author Rossen Stoyanchev
* @since 4.2
*/
public class ResponseBodyEmitter {
private Handler handler;
/* Cache for objects sent before handler is set. */
private final Map<Object, MediaType> initHandlerCache = new LinkedHashMap<Object, MediaType>(10);
private volatile boolean complete;
private Throwable failure;
/**
* Invoked after the response is updated with the status code and headers,
* if the ResponseBodyEmitter is wrapped in a ResponseEntity, but before the
* response is committed, i.e. before the response body has been written to.
* <p>The default implementation is empty.
*/
protected void extendResponse(ServerHttpResponse outputMessage) {
}
void initialize(Handler handler) throws IOException {
synchronized (this) {
this.handler = handler;
for (Map.Entry<Object, MediaType> entry : this.initHandlerCache.entrySet()) {
try {
sendInternal(entry.getKey(), entry.getValue());
}
catch (Throwable ex) {
return;
}
}
if (this.complete) {
if (this.failure != null) {
this.handler.completeWithError(this.failure);
}
else {
this.handler.complete();
}
}
}
}
/**
* Write the given object to the response.
* <p>If any exception occurs a dispatch is made back to the app server where
* Spring MVC will pass the exception through its exception handling mechanism.
* @param object the object to write
* @throws IOException raised when an I/O error occurs
* @throws java.lang.IllegalStateException wraps any other errors
*/
public void send(Object object) throws IOException {
send(object, null);
}
/**
* Write the given object to the response also using a MediaType hint.
* <p>If any exception occurs a dispatch is made back to the app server where
* Spring MVC will pass the exception through its exception handling mechanism.
* @param object the object to write
* @param mediaType a MediaType hint for selecting an HttpMessageConverter
* @throws IOException raised when an I/O error occurs
* @throws java.lang.IllegalStateException wraps any other errors
*/
public void send(Object object, MediaType mediaType) throws IOException {
Assert.state(!this.complete, "ResponseBodyEmitter is already set complete.");
sendInternal(object, mediaType);
}
private void sendInternal(Object object, MediaType mediaType) throws IOException {
if (object == null) {
return;
}
if (handler == null) {
synchronized (this) {
if (handler == null) {
this.initHandlerCache.put(object, mediaType);
return;
}
}
}
try {
this.handler.send(object, mediaType);
}
catch(IOException ex){
this.handler.completeWithError(ex);
throw ex;
}
catch(Throwable ex){
this.handler.completeWithError(ex);
throw new IllegalStateException("Failed to send " + object, ex);
}
}
/**
* Complete request processing.
* <p>A dispatch is made into the app server where Spring MVC completes
* asynchronous request processing.
*/
public void complete() {
synchronized (this) {
this.complete = true;
if (handler != null) {
this.handler.complete();
}
}
}
/**
* Complete request processing with an error.
* <p>A dispatch is made into the app server where Spring MVC will pass the
* exception through its exception handling mechanism.
*/
public void completeWithError(Throwable ex) {
synchronized (this) {
this.complete = true;
this.failure = ex;
if (handler != null) {
this.handler.completeWithError(ex);
}
}
}
/**
* Handle sent objects and complete request processing.
*/
interface Handler {
void send(Object data, MediaType mediaType) throws IOException;
void complete();
void completeWithError(Throwable failure);
}
}

View File

@ -0,0 +1,211 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.async.DeferredResult;
import org.springframework.web.context.request.async.WebAsyncUtils;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
import org.springframework.web.method.support.ModelAndViewContainer;
/**
* Supports return values of type {@link ResponseBodyEmitter} and also
* {@code ResponseEntity<ResponseBodyEmitter>}.
*
* @author Rossen Stoyanchev
* @since 4.2
*/
public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodReturnValueHandler {
private static final Log logger = LogFactory.getLog(ResponseBodyEmitterReturnValueHandler.class);
private final List<HttpMessageConverter<?>> messageConverters;
public ResponseBodyEmitterReturnValueHandler(List<HttpMessageConverter<?>> messageConverters) {
Assert.notEmpty(messageConverters, "'messageConverters' must not be empty");
this.messageConverters = messageConverters;
}
@Override
public boolean supportsReturnType(MethodParameter returnType) {
if (ResponseBodyEmitter.class.isAssignableFrom(returnType.getParameterType())) {
return true;
}
else if (ResponseEntity.class.isAssignableFrom(returnType.getParameterType())) {
Type paramType = returnType.getGenericParameterType();
if (paramType instanceof ParameterizedType) {
ParameterizedType type = (ParameterizedType) paramType;
Type[] typeArguments = type.getActualTypeArguments();
if (typeArguments.length == 1) {
return ResponseBodyEmitter.class.isAssignableFrom((Class<?>) typeArguments[0]);
}
}
}
return false;
}
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
if (returnValue == null) {
mavContainer.setRequestHandled(true);
return;
}
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
ServerHttpResponse outputMessage = new ServletServerHttpResponse(response);
if (ResponseEntity.class.isAssignableFrom(returnValue.getClass())) {
ResponseEntity<?> responseEntity = (ResponseEntity<?>) returnValue;
outputMessage.setStatusCode(responseEntity.getStatusCode());
outputMessage.getHeaders().putAll(responseEntity.getHeaders());
returnValue = responseEntity.getBody();
if (returnValue == null) {
mavContainer.setRequestHandled(true);
return;
}
}
Assert.isInstanceOf(ResponseBodyEmitter.class, returnValue);
ResponseBodyEmitter emitter = (ResponseBodyEmitter) returnValue;
emitter.extendResponse(outputMessage);
// Commit the response and wrap to ignore further header changes
outputMessage.getBody();
outputMessage = new StreamingServletServerHttpResponse(outputMessage);
DeferredResult<?> deferredResult = new DeferredResult<Object>();
WebAsyncUtils.getAsyncManager(webRequest).startDeferredResultProcessing(deferredResult, mavContainer);
HttpMessageConvertingHandler handler = new HttpMessageConvertingHandler(outputMessage, deferredResult);
emitter.initialize(handler);
}
/**
* ResponseBodyEmitter.Handler that writes with HttpMessageConverter's.
*/
private class HttpMessageConvertingHandler implements ResponseBodyEmitter.Handler {
private final ServerHttpResponse outputMessage;
private final DeferredResult<?> deferredResult;
public HttpMessageConvertingHandler(ServerHttpResponse outputMessage, DeferredResult<?> deferredResult) {
this.outputMessage = outputMessage;
this.deferredResult = deferredResult;
}
@Override
public void send(Object data, MediaType mediaType) throws IOException {
sendInternal(data, mediaType);
}
@SuppressWarnings("unchecked")
private <T> void sendInternal(T data, MediaType mediaType) throws IOException {
for (HttpMessageConverter<?> converter : ResponseBodyEmitterReturnValueHandler.this.messageConverters) {
if (converter.canWrite(data.getClass(), mediaType)) {
((HttpMessageConverter<T>) converter).write(data, mediaType, this.outputMessage);
this.outputMessage.flush();
if (logger.isDebugEnabled()) {
logger.debug("Written [" + data + "] using [" + converter + "]");
}
return;
}
}
throw new IllegalArgumentException("No suitable converter for " + data);
}
@Override
public void complete() {
this.deferredResult.setResult(null);
}
@Override
public void completeWithError(Throwable failure) {
this.deferredResult.setErrorResult(failure);
}
}
/**
* Wrap to silently ignore header changes HttpMessageConverter's that would
* otherwise cause HttpHeaders to raise exceptions.
*/
private static class StreamingServletServerHttpResponse implements ServerHttpResponse {
private final ServerHttpResponse delegate;
private final HttpHeaders mutableHeaders = new HttpHeaders();
public StreamingServletServerHttpResponse(ServerHttpResponse delegate) {
this.delegate = delegate;
this.mutableHeaders.putAll(delegate.getHeaders());
}
@Override
public void setStatusCode(HttpStatus status) {
this.delegate.setStatusCode(status);
}
@Override
public HttpHeaders getHeaders() {
return this.mutableHeaders;
}
@Override
public OutputStream getBody() throws IOException {
return this.delegate.getBody();
}
@Override
public void flush() throws IOException {
this.delegate.flush();
}
@Override
public void close() {
this.delegate.close();
}
}
}

View File

@ -25,6 +25,7 @@ import java.util.concurrent.Callable;
import org.springframework.core.MethodParameter; import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType; import org.springframework.core.ResolvableType;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@ -260,7 +261,16 @@ public class ServletInvocableHandlerMethod extends InvocableHandlerMethod {
@Override @Override
public Class<?> getParameterType() { public Class<?> getParameterType() {
return (this.returnValue != null ? this.returnValue.getClass() : this.returnType.getRawClass()); if (this.returnValue != null) {
return this.returnValue.getClass();
}
Class<?> parameterType = super.getParameterType();
if (ResponseBodyEmitter.class.isAssignableFrom(parameterType)) {
return parameterType;
}
Assert.isTrue(!ResolvableType.NONE.equals(this.returnType), "Expected one of" +
"Callable, DeferredResult, or ListenableFuture: " + super.getParameterType());
return this.returnType.getRawClass();
} }
@Override @Override

View File

@ -0,0 +1,233 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpResponse;
/**
* A specialization of
* {@link org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter
* ResponseBodyEmitter} for sending
* <a href="http://www.w3.org/TR/eventsource/">Server-Sent Events</a>.
*
* @author Rossen Stoyanchev
* @since 4.2
*/
public class SseEmitter extends ResponseBodyEmitter {
public static final MediaType TEXT_PLAIN = new MediaType("text", "plain", Charset.forName("UTF-8"));
@Override
protected void extendResponse(ServerHttpResponse outputMessage) {
super.extendResponse(outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
if (headers.getContentType() == null) {
headers.setContentType(new MediaType("text", "event-stream"));
}
}
/**
* Send the object formatted as a single SSE "data" line. It's equivalent to:
* <pre>
*
* // static import of SseEmitter.*
*
* SseEmitter emitter = new SseEmitter();
* emitter.send(event().data(myObject));
* </pre>
* @param object the object to write
* @throws IOException raised when an I/O error occurs
* @throws java.lang.IllegalStateException wraps any other errors
*/
@Override
public void send(Object object) throws IOException {
send(object, null);
}
/**
* Send the object formatted as a single SSE "data" line. It's equivalent to:
* <pre>
*
* // static import of SseEmitter.*
*
* SseEmitter emitter = new SseEmitter();
* emitter.send(event().data(myObject, MediaType.APPLICATION_JSON));
* </pre>
* @param object the object to write
* @param mediaType a MediaType hint for selecting an HttpMessageConverter
* @throws IOException raised when an I/O error occurs
* @throws java.lang.IllegalStateException wraps any other errors
*/
@Override
public void send(Object object, MediaType mediaType) throws IOException {
if (object == null) {
return;
}
send(event().data(object, mediaType));
}
/**
* Send an SSE event prepared with the given builder. For example:
* <pre>
*
* // static import of SseEmitter
*
* SseEmitter emitter = new SseEmitter();
* emitter.send(event().name("update").id("1").data(myObject));
* </pre>
* @param builder a builder for an SSE formatted event.
* @throws IOException raised when an I/O error occurs
* @throws java.lang.IllegalStateException wraps any other errors
*/
public void send(SseEventBuilder builder) throws IOException {
Map<Object, MediaType> map = builder.build();
for (Map.Entry<Object, MediaType> entry : map.entrySet()) {
super.send(entry.getKey(), entry.getValue());
}
}
public static SseEventBuilder event() {
return new DefaultSseEventBuilder();
}
/**
* A builder for an SSE event.
*/
public interface SseEventBuilder {
/**
* Add an SSE "comment" line.
*/
SseEventBuilder comment(String comment);
/**
* Add an SSE "event" line.
*/
SseEventBuilder name(String eventName);
/**
* Add an SSE "id" line.
*/
SseEventBuilder id(String id);
/**
* Add an SSE "event" line.
*/
SseEventBuilder reconnectTime(long reconnectTimeMillis);
/**
* Add an SSE "data" line.
*/
SseEventBuilder data(Object object);
/**
* Add an SSE "data" line.
*/
SseEventBuilder data(Object object, MediaType mediaType);
/**
* Return a map with objects that represent the data to be written to
* the response as well as the required SSE text formatting that
* surrounds it.
*/
Map<Object, MediaType> build();
}
/**
* Default implementation of SseEventBuilder.
*/
private static class DefaultSseEventBuilder implements SseEventBuilder {
private final Map<Object, MediaType> map = new LinkedHashMap<Object, MediaType>(4);
private StringBuilder sb;
@Override
public SseEventBuilder comment(String comment) {
append(":").append(comment != null ? comment : "").append("\n");
return this;
}
@Override
public SseEventBuilder name(String name) {
append("name:").append(name != null ? name : "").append("\n");
return this;
}
@Override
public SseEventBuilder id(String id) {
append("id:").append(id != null ? id : "").append("\n");
return this;
}
@Override
public SseEventBuilder reconnectTime(long reconnectTimeMillis) {
append("retry:").append(String.valueOf(reconnectTimeMillis)).append("\n");
return this;
}
@Override
public SseEventBuilder data(Object object) {
return data(object, null);
}
@Override
public SseEventBuilder data(Object object, MediaType mediaType) {
append("data:");
saveAppendedText();
this.map.put(object, mediaType);
append("\n");
return this;
}
DefaultSseEventBuilder append(String text) {
if (this.sb == null) {
this.sb = new StringBuilder();
}
this.sb.append(text);
return this;
}
private void saveAppendedText() {
if (this.sb != null) {
this.map.put(this.sb.toString(), TEXT_PLAIN);
this.sb = null;
}
}
@Override
public Map<Object, MediaType> build() {
if (this.sb == null || this.sb.length() == 0 && this.map.isEmpty()) {
return Collections.<Object, MediaType>emptyMap();
}
append("\n");
saveAppendedText();
return this.map;
}
}
}

View File

@ -0,0 +1,235 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.core.MethodParameter;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.mock.web.test.MockAsyncContext;
import org.springframework.mock.web.test.MockHttpServletRequest;
import org.springframework.mock.web.test.MockHttpServletResponse;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.async.AsyncWebRequest;
import org.springframework.web.context.request.async.StandardServletAsyncWebRequest;
import org.springframework.web.context.request.async.WebAsyncUtils;
import org.springframework.web.method.support.ModelAndViewContainer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event;
/**
* Unit tests for ResponseBodyEmitterReturnValueHandler.
* @author Rossen Stoyanchev
*/
public class ResponseBodyEmitterReturnValueHandlerTests {
private ResponseBodyEmitterReturnValueHandler handler;
private ModelAndViewContainer mavContainer;
private NativeWebRequest webRequest;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
@Before
public void setUp() throws Exception {
List<HttpMessageConverter<?>> converters = Arrays.asList(
new StringHttpMessageConverter(), new MappingJackson2HttpMessageConverter());
this.handler = new ResponseBodyEmitterReturnValueHandler(converters);
this.mavContainer = new ModelAndViewContainer();
this.request = new MockHttpServletRequest();
this.response = new MockHttpServletResponse();
this.webRequest = new ServletWebRequest(this.request, this.response);
AsyncWebRequest asyncWebRequest = new StandardServletAsyncWebRequest(this.request, this.response);
WebAsyncUtils.getAsyncManager(this.webRequest).setAsyncWebRequest(asyncWebRequest);
this.request.setAsyncSupported(true);
}
@Test
public void supportsReturnType() throws Exception {
assertTrue(this.handler.supportsReturnType(returnType(TestController.class, "handle")));
assertTrue(this.handler.supportsReturnType(returnType(TestController.class, "handleSse")));
assertTrue(this.handler.supportsReturnType(returnType(TestController.class, "handleResponseEntity")));
assertFalse(this.handler.supportsReturnType(returnType(TestController.class, "handleResponseEntityString")));
}
@Test
public void responseBodyEmitter() throws Exception {
MethodParameter returnType = returnType(TestController.class, "handle");
ResponseBodyEmitter emitter = new ResponseBodyEmitter();
this.handler.handleReturnValue(emitter, returnType, this.mavContainer, this.webRequest);
assertTrue(this.request.isAsyncStarted());
assertEquals("", this.response.getContentAsString());
SimpleBean bean = new SimpleBean();
bean.setId(1L);
bean.setName("Joe");
emitter.send(bean);
emitter.send("\n");
bean.setId(2L);
bean.setName("John");
emitter.send(bean);
emitter.send("\n");
bean.setId(3L);
bean.setName("Jason");
emitter.send(bean);
assertEquals("{\"id\":1,\"name\":\"Joe\"}\n" +
"{\"id\":2,\"name\":\"John\"}\n" +
"{\"id\":3,\"name\":\"Jason\"}",
this.response.getContentAsString());
MockAsyncContext asyncContext = (MockAsyncContext) this.request.getAsyncContext();
assertNull(asyncContext.getDispatchedPath());
emitter.complete();
assertNotNull(asyncContext.getDispatchedPath());
}
@Test
public void sseEmitter() throws Exception {
MethodParameter returnType = returnType(TestController.class, "handleSse");
SseEmitter emitter = new SseEmitter();
this.handler.handleReturnValue(emitter, returnType, this.mavContainer, this.webRequest);
assertTrue(this.request.isAsyncStarted());
assertEquals(200, this.response.getStatus());
assertEquals("text/event-stream", this.response.getContentType());
SimpleBean bean1 = new SimpleBean();
bean1.setId(1L);
bean1.setName("Joe");
SimpleBean bean2 = new SimpleBean();
bean2.setId(2L);
bean2.setName("John");
emitter.send(event().comment("a test").name("update").id("1").reconnectTime(5000L).data(bean1).data(bean2));
assertEquals(":a test\n" +
"name:update\n" +
"id:1\n" +
"retry:5000\n" +
"data:{\"id\":1,\"name\":\"Joe\"}\n" +
"data:{\"id\":2,\"name\":\"John\"}\n" +
"\n",
this.response.getContentAsString());
}
@Test
public void responseEntitySse() throws Exception {
MethodParameter returnType = returnType(TestController.class, "handleResponseEntitySse");
ResponseEntity<SseEmitter> emitter = ResponseEntity.ok().header("foo", "bar").body(new SseEmitter());
this.handler.handleReturnValue(emitter, returnType, this.mavContainer, this.webRequest);
assertTrue(this.request.isAsyncStarted());
assertEquals(200, this.response.getStatus());
assertEquals("text/event-stream", this.response.getContentType());
assertEquals("bar", this.response.getHeader("foo"));
}
@Test
public void responseEntitySseNoContent() throws Exception {
MethodParameter returnType = returnType(TestController.class, "handleResponseEntitySse");
ResponseEntity<?> emitter = ResponseEntity.noContent().build();
this.handler.handleReturnValue(emitter, returnType, this.mavContainer, this.webRequest);
assertFalse(this.request.isAsyncStarted());
assertEquals(204, this.response.getStatus());
}
private MethodParameter returnType(Class<?> clazz, String methodName) throws NoSuchMethodException {
Method method = clazz.getDeclaredMethod(methodName);
return new MethodParameter(method, -1);
}
@SuppressWarnings("unused")
private static class TestController {
private ResponseBodyEmitter handle() {
return null;
}
private ResponseEntity<ResponseBodyEmitter> handleResponseEntity() {
return null;
}
private SseEmitter handleSse() {
return null;
}
private ResponseEntity<SseEmitter> handleResponseEntitySse() {
return null;
}
private ResponseEntity<String> handleResponseEntityString() {
return null;
}
}
private static class SimpleBean {
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Unit tests for {@link ResponseBodyEmitter}.
* @author Rossen Stoyanchev
*/
public class ResponseBodyEmitterTests {
private ResponseBodyEmitter emitter;
@Mock
private ResponseBodyEmitter.Handler handler;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
this.emitter = new ResponseBodyEmitter();
}
@Test
public void sendBeforeHandlerInitialized() throws Exception {
this.emitter.send("foo", MediaType.TEXT_PLAIN);
this.emitter.send("bar", MediaType.TEXT_PLAIN);
this.emitter.complete();
verifyNoMoreInteractions(this.handler);
this.emitter.initialize(this.handler);
verify(this.handler).send("foo", MediaType.TEXT_PLAIN);
verify(this.handler).send("bar", MediaType.TEXT_PLAIN);
verify(this.handler).complete();
verifyNoMoreInteractions(this.handler);
}
@Test
public void sendBeforeHandlerInitializedWithError() throws Exception {
IllegalStateException ex = new IllegalStateException();
this.emitter.send("foo", MediaType.TEXT_PLAIN);
this.emitter.send("bar", MediaType.TEXT_PLAIN);
this.emitter.completeWithError(ex);
verifyNoMoreInteractions(this.handler);
this.emitter.initialize(this.handler);
verify(this.handler).send("foo", MediaType.TEXT_PLAIN);
verify(this.handler).send("bar", MediaType.TEXT_PLAIN);
verify(this.handler).completeWithError(ex);
verifyNoMoreInteractions(this.handler);
}
@Test(expected = IllegalStateException.class)
public void sendFailsAfterComplete() throws Exception {
this.emitter.complete();
this.emitter.send("foo");
}
@Test
public void sendAfterHandlerInitialized() throws Exception {
this.emitter.initialize(this.handler);
verifyNoMoreInteractions(this.handler);
this.emitter.send("foo", MediaType.TEXT_PLAIN);
this.emitter.send("bar", MediaType.TEXT_PLAIN);
this.emitter.complete();
verify(this.handler).send("foo", MediaType.TEXT_PLAIN);
verify(this.handler).send("bar", MediaType.TEXT_PLAIN);
verify(this.handler).complete();
verifyNoMoreInteractions(this.handler);
}
@Test
public void sendAfterHandlerInitializedWithError() throws Exception {
this.emitter.initialize(this.handler);
verifyNoMoreInteractions(this.handler);
IllegalStateException ex = new IllegalStateException();
this.emitter.send("foo", MediaType.TEXT_PLAIN);
this.emitter.send("bar", MediaType.TEXT_PLAIN);
this.emitter.completeWithError(ex);
verify(this.handler).send("foo", MediaType.TEXT_PLAIN);
verify(this.handler).send("bar", MediaType.TEXT_PLAIN);
verify(this.handler).completeWithError(ex);
verifyNoMoreInteractions(this.handler);
}
@Test
public void sendWithError() throws Exception {
this.emitter.initialize(this.handler);
verifyNoMoreInteractions(this.handler);
IOException failure = new IOException();
doThrow(failure).when(this.handler).send("foo", MediaType.TEXT_PLAIN);
try {
this.emitter.send("foo", MediaType.TEXT_PLAIN);
fail("Expected exception");
}
catch (IOException ex) {
// expected
}
verify(this.handler).send("foo", MediaType.TEXT_PLAIN);
verify(this.handler).completeWithError(failure);
verifyNoMoreInteractions(this.handler);
}
}

View File

@ -0,0 +1,152 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.http.MediaType;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event;
/**
* Unit tests for {@link org.springframework.web.servlet.mvc.method.annotation.SseEmitter}.
* @author Rossen Stoyanchev
*/
public class SseEmitterTests {
private SseEmitter emitter;
private TestHandler handler;
@Before
public void setup() throws IOException {
this.handler = new TestHandler();
this.emitter = new SseEmitter();
this.emitter.initialize(this.handler);
}
@Test
public void send() throws Exception {
this.emitter.send("foo");
this.handler.assertSentObjectCount(3);
this.handler.assertObject(0, "data:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(1, "foo");
this.handler.assertObject(2, "\n\n", SseEmitter.TEXT_PLAIN);
}
@Test
public void sendWithMediaType() throws Exception {
this.emitter.send("foo", MediaType.TEXT_PLAIN);
this.handler.assertSentObjectCount(3);
this.handler.assertObject(0, "data:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(1, "foo", MediaType.TEXT_PLAIN);
this.handler.assertObject(2, "\n\n", SseEmitter.TEXT_PLAIN);
}
@Test
public void sendEventEmpty() throws Exception {
this.emitter.send(event());
this.handler.assertSentObjectCount(0);
}
@Test
public void sendEventWithDataLine() throws Exception {
this.emitter.send(event().data("foo"));
this.handler.assertSentObjectCount(3);
this.handler.assertObject(0, "data:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(1, "foo");
this.handler.assertObject(2, "\n\n", SseEmitter.TEXT_PLAIN);
}
@Test
public void sendEventWithTwoDataLines() throws Exception {
this.emitter.send(event().data("foo").data("bar"));
this.handler.assertSentObjectCount(5);
this.handler.assertObject(0, "data:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(1, "foo");
this.handler.assertObject(2, "\ndata:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(3, "bar");
this.handler.assertObject(4, "\n\n", SseEmitter.TEXT_PLAIN);
}
@Test
public void sendEventFull() throws Exception {
this.emitter.send(event().comment("blah").name("test").reconnectTime(5000L).id("1").data("foo"));
this.handler.assertSentObjectCount(3);
this.handler.assertObject(0, ":blah\nname:test\nretry:5000\nid:1\ndata:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(1, "foo");
this.handler.assertObject(2, "\n\n", SseEmitter.TEXT_PLAIN);
}
@Test
public void sendEventFullWithTwoDataLinesInTheMiddle() throws Exception {
this.emitter.send(event().comment("blah").data("foo").data("bar").name("test").reconnectTime(5000L).id("1"));
this.handler.assertSentObjectCount(5);
this.handler.assertObject(0, ":blah\ndata:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(1, "foo");
this.handler.assertObject(2, "\ndata:", SseEmitter.TEXT_PLAIN);
this.handler.assertObject(3, "bar");
this.handler.assertObject(4, "\nname:test\nretry:5000\nid:1\n\n", SseEmitter.TEXT_PLAIN);
}
private static class TestHandler implements ResponseBodyEmitter.Handler {
private List<Object> objects = new ArrayList<>();
private List<MediaType> mediaTypes = new ArrayList<>();
public void assertSentObjectCount(int size) {
assertEquals(size, this.objects.size());
}
public void assertObject(int index, Object object) {
assertObject(index, object, null);
}
public void assertObject(int index, Object object, MediaType mediaType) {
assertTrue(index <= this.objects.size());
assertEquals(object, this.objects.get(index));
assertEquals(mediaType, this.mediaTypes.get(index));
}
@Override
public void send(Object data, MediaType mediaType) throws IOException {
this.objects.add(data);
this.mediaTypes.add(mediaType);
}
@Override
public void complete() {
}
@Override
public void completeWithError(Throwable failure) {
}
}
}

View File

@ -31063,6 +31063,10 @@ The following are the supported return types:
value from a thread of its own choosing. value from a thread of its own choosing.
* A `ListenableFuture<?>` can be returned when the application wants to produce the return * A `ListenableFuture<?>` can be returned when the application wants to produce the return
value from a thread of its own choosing. value from a thread of its own choosing.
* A `ResponseBodyEmitter` can be returned to write multiple objects to the response
asynchronously; also supported as the body within a `ResponseEntity`.
* An `SseEmitter` can be returned to write Server-Sent Events to the response
asynchronously; also supported as the body within a `ResponseEntity`.
* Any other return type is considered to be a single model attribute to be exposed to * Any other return type is considered to be a single model attribute to be exposed to
the view, using the attribute name specified through `@ModelAttribute` at the method the view, using the attribute name specified through `@ModelAttribute` at the method
level (or the default attribute name based on the return type class name). The model level (or the default attribute name based on the return type class name). The model