Browse Source
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-12212pull/729/head
11 changed files with 1189 additions and 2 deletions
@ -0,0 +1,198 @@
@@ -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> |
||||
* @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); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,211 @@
@@ -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(); |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,233 @@
@@ -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; |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,235 @@
@@ -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; |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,137 @@
@@ -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); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,152 @@
@@ -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) { |
||||
} |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue