From a32b5e61d0b5dff9c09cc3c7d7b8e5e68ac69ae5 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Thu, 8 Jan 2015 11:34:41 -0500 Subject: [PATCH] Add support for Server-Sent Events This commit adds ResponseBodyEmitter and SseEmitter (and also ResponseEntity and ResponseEntity) as new return value types supported on @RequestMapping controller methods. See Javadoc on respective types for more details. Issue: SPR-12212 --- .../web/bind/annotation/RequestMapping.java | 6 + .../RequestMappingHandlerAdapter.java | 1 + .../annotation/ResponseBodyEmitter.java | 198 +++++++++++++++ ...ResponseBodyEmitterReturnValueHandler.java | 211 ++++++++++++++++ .../ServletInvocableHandlerMethod.java | 12 +- .../mvc/method/annotation/SseEmitter.java | 233 +++++++++++++++++ .../support/ServletUriComponentsBuilder.java | 2 +- ...nseBodyEmitterReturnValueHandlerTests.java | 235 ++++++++++++++++++ .../annotation/ResponseBodyEmitterTests.java | 137 ++++++++++ .../method/annotation/SseEmitterTests.java | 152 +++++++++++ src/asciidoc/index.adoc | 4 + 11 files changed, 1189 insertions(+), 2 deletions(-) create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java index 2d68272d10..1817fb9dfb 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java @@ -213,6 +213,12 @@ import java.util.concurrent.Callable; *
  • A {@link org.springframework.util.concurrent.ListenableFuture} * which the application uses to produce a return value in a separate * thread of its own choosing, as an alternative to returning a Callable. + *
  • 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}.
  • + *
  • 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}.
  • *
  • {@code void} if the method handles the response itself (by * writing the response content directly, declaring an argument of type * {@link javax.servlet.ServletResponse} / {@link javax.servlet.http.HttpServletResponse} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java index 36c2312635..cf7bca2108 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java @@ -632,6 +632,7 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter handlers.add(new ModelAndViewMethodReturnValueHandler()); handlers.add(new ModelMethodProcessor()); handlers.add(new ViewMethodReturnValueHandler()); + handlers.add(new ResponseBodyEmitterReturnValueHandler(getMessageConverters())); handlers.add(new HttpEntityMethodProcessor( getMessageConverters(), this.contentNegotiationManager, this.responseBodyAdvice)); handlers.add(new HttpHeadersReturnValueHandler()); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java new file mode 100644 index 0000000000..b28dba8c0b --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java @@ -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}. + * + *

    Supported as a return type on its own as well as within a + * {@link org.springframework.http.ResponseEntity ResponseEntity}. + * + *

    + * @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();
    + * 
    + * + *

    Note: 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 initHandlerCache = new LinkedHashMap(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. + *

    The default implementation is empty. + */ + protected void extendResponse(ServerHttpResponse outputMessage) { + } + + void initialize(Handler handler) throws IOException { + synchronized (this) { + this.handler = handler; + for (Map.Entry 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