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.
+ * 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.
+ *
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.
+ *
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.
+ *
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);
+ }
+
+}
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java
new file mode 100644
index 0000000000..9072a4574f
--- /dev/null
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java
@@ -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}.
+ *
+ * @author Rossen Stoyanchev
+ * @since 4.2
+ */
+public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodReturnValueHandler {
+
+ private static final Log logger = LogFactory.getLog(ResponseBodyEmitterReturnValueHandler.class);
+
+ private final List> messageConverters;
+
+
+ public ResponseBodyEmitterReturnValueHandler(List> 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();
+ 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 void sendInternal(T data, MediaType mediaType) throws IOException {
+ for (HttpMessageConverter> converter : ResponseBodyEmitterReturnValueHandler.this.messageConverters) {
+ if (converter.canWrite(data.getClass(), mediaType)) {
+ ((HttpMessageConverter) 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();
+ }
+ }
+
+}
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethod.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethod.java
index d48b6502a7..e0555786a7 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethod.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethod.java
@@ -25,6 +25,7 @@ import java.util.concurrent.Callable;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpStatus;
+import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ResponseStatus;
@@ -260,7 +261,16 @@ public class ServletInvocableHandlerMethod extends InvocableHandlerMethod {
@Override
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
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java
new file mode 100644
index 0000000000..874af6c952
--- /dev/null
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java
@@ -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
+ * Server-Sent Events .
+ *
+ * @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:
+ *
+ *
+ * // static import of SseEmitter.*
+ *
+ * SseEmitter emitter = new SseEmitter();
+ * emitter.send(event().data(myObject));
+ *
+ * @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:
+ *
+ *
+ * // static import of SseEmitter.*
+ *
+ * SseEmitter emitter = new SseEmitter();
+ * emitter.send(event().data(myObject, MediaType.APPLICATION_JSON));
+ *
+ * @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:
+ *
+ *
+ * // static import of SseEmitter
+ *
+ * SseEmitter emitter = new SseEmitter();
+ * emitter.send(event().name("update").id("1").data(myObject));
+ *
+ * @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 map = builder.build();
+ for (Map.Entry 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 build();
+ }
+
+ /**
+ * Default implementation of SseEventBuilder.
+ */
+ private static class DefaultSseEventBuilder implements SseEventBuilder {
+
+ private final Map map = new LinkedHashMap(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 build() {
+ if (this.sb == null || this.sb.length() == 0 && this.map.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ append("\n");
+ saveAppendedText();
+ return this.map;
+ }
+ }
+
+}
diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/support/ServletUriComponentsBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/support/ServletUriComponentsBuilder.java
index e669adf07d..1ddca18ec9 100644
--- a/spring-webmvc/src/main/java/org/springframework/web/servlet/support/ServletUriComponentsBuilder.java
+++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/support/ServletUriComponentsBuilder.java
@@ -79,7 +79,7 @@ public class ServletUriComponentsBuilder extends UriComponentsBuilder {
* {@code "/"} or {@code "*.do"}, the result will be the same as
* if calling {@link #fromContextPath(HttpServletRequest)}.
*/
- public static ServletUriComponentsBuilder fromServletMapping(HttpServletRequest request) {
+ public static ServletUriComponentsBuilder fromServletMapping(HttpServletRequest request) {
ServletUriComponentsBuilder builder = fromContextPath(request);
if (StringUtils.hasText(new UrlPathHelper().getPathWithinServletMapping(request))) {
builder.path(request.getServletPath());
diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java
new file mode 100644
index 0000000000..ed0034f715
--- /dev/null
+++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java
@@ -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> 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 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 handleResponseEntity() {
+ return null;
+ }
+
+ private SseEmitter handleSse() {
+ return null;
+ }
+
+ private ResponseEntity handleResponseEntitySse() {
+ return null;
+ }
+
+ private ResponseEntity 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;
+ }
+ }
+
+}
diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterTests.java
new file mode 100644
index 0000000000..56014307dc
--- /dev/null
+++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterTests.java
@@ -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);
+ }
+
+}
diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java
new file mode 100644
index 0000000000..87ecf7c2e7
--- /dev/null
+++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java
@@ -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 objects = new ArrayList<>();
+
+ private List 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) {
+ }
+ }
+
+}
diff --git a/src/asciidoc/index.adoc b/src/asciidoc/index.adoc
index 565122dd8b..d28e9992ac 100644
--- a/src/asciidoc/index.adoc
+++ b/src/asciidoc/index.adoc
@@ -31063,6 +31063,10 @@ The following are the supported return types:
value from a thread of its own choosing.
* A `ListenableFuture>` can be returned when the application wants to produce the return
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
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