Add Server-Sent Events support

Flux<SseEvent> is Spring Web Reactive equivalent to Spring MVC
SseEmitter type. It allows to send Server-Sent Events in a reactive way.
Sending Flux<String> or Flux<Pojo> is equivalent to sending
Flux<SseEvent> with the data property set to the String or
Pojo value. For example:

@RestController
public class SseController {

	@RequestMapping("/sse/string")
	Flux<String> string() {
		return Flux.interval(Duration.ofSeconds(1)).map(l -> "foo " + l);
	}

	@RequestMapping("/sse/person")
	Flux<Person> person() {
		return Flux.interval(Duration.ofSeconds(1)).map(l -> new Person(Long.toString(l), "foo", "bar"));
	}

	@RequestMapping("/sse-raw")
	Flux<SseEvent> sse() {
		return Flux.interval(Duration.ofSeconds(1)).map(l -> {
			SseEvent event = new SseEvent();
			event.setId(Long.toString(l));
			event.setData("foo\nbar");
			event.setComment("bar\nbaz");
			return event;
		});
	}
}
This commit is contained in:
Sebastien Deleuze
2016-06-16 12:03:53 +02:00
parent aeb35787d7
commit 9004812231
5 changed files with 767 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
/*
* Copyright 2002-2016 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.core.codec.support;
import java.util.Arrays;
import org.junit.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.test.TestSubscriber;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.util.MimeType;
import org.springframework.web.reactive.sse.SseEvent;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* @author Sebastien Deleuze
*/
public class SseEventEncoderTests extends AbstractDataBufferAllocatingTestCase {
@Test
public void nullMimeType() {
SseEventEncoder encoder = new SseEventEncoder(new StringEncoder(), Arrays.asList(new JacksonJsonEncoder()));
assertTrue(encoder.canEncode(ResolvableType.forClass(Object.class), null));
}
@Test
public void unsupportedMimeType() {
SseEventEncoder encoder = new SseEventEncoder(new StringEncoder(), Arrays.asList(new JacksonJsonEncoder()));
assertFalse(encoder.canEncode(ResolvableType.forClass(Object.class), new MimeType("foo", "bar")));
}
@Test
public void supportedMimeType() {
SseEventEncoder encoder = new SseEventEncoder(new StringEncoder(), Arrays.asList(new JacksonJsonEncoder()));
assertTrue(encoder.canEncode(ResolvableType.forClass(Object.class), new MimeType("text", "event-stream")));
}
@Test
public void encodeServerSentEvent() {
SseEventEncoder encoder = new SseEventEncoder(new StringEncoder(), Arrays.asList(new JacksonJsonEncoder()));
SseEvent event = new SseEvent();
event.setId("c42");
event.setName("foo");
event.setComment("bla\nbla bla\nbla bla bla");
event.setReconnectTime(123L);
Mono<SseEvent> source = Mono.just(event);
Flux<DataBuffer> output = encoder.encode(source, this.dataBufferFactory,
ResolvableType.forClass(SseEvent.class), new MimeType("text", "event-stream"));
TestSubscriber
.subscribe(output)
.assertNoError()
.assertValuesWith(
stringConsumer(
"id:c42\n" +
"event:foo\n" +
"retry:123\n" +
":bla\n:bla bla\n:bla bla bla\n"),
stringConsumer("\n")
);
}
@Test
public void encodeString() {
SseEventEncoder encoder = new SseEventEncoder(new StringEncoder(), Arrays.asList(new JacksonJsonEncoder()));
Flux<String> source = Flux.just("foo", "bar");
Flux<DataBuffer> output = encoder.encode(source, this.dataBufferFactory,
ResolvableType.forClass(String.class), new MimeType("text", "event-stream"));
TestSubscriber
.subscribe(output)
.assertNoError()
.assertValuesWith(
stringConsumer("data:foo\n"),
stringConsumer("\n"),
stringConsumer("data:bar\n"),
stringConsumer("\n")
);
}
@Test
public void encodeMultilineString() {
SseEventEncoder encoder = new SseEventEncoder(new StringEncoder(), Arrays.asList(new JacksonJsonEncoder()));
Flux<String> source = Flux.just("foo\nbar", "foo\nbaz");
Flux<DataBuffer> output = encoder.encode(source, this.dataBufferFactory,
ResolvableType.forClass(String.class), new MimeType("text", "event-stream"));
TestSubscriber
.subscribe(output)
.assertNoError()
.assertValuesWith(
stringConsumer("data:foo\ndata:bar\n"),
stringConsumer("\n"),
stringConsumer("data:foo\ndata:baz\n"),
stringConsumer("\n")
);
}
@Test
public void encodePojo() {
SseEventEncoder encoder = new SseEventEncoder(new StringEncoder(), Arrays.asList(new JacksonJsonEncoder()));
Flux<Pojo> source = Flux.just(new Pojo("foofoo", "barbar"), new Pojo("foofoofoo", "barbarbar"));
Flux<DataBuffer> output = encoder.encode(source, this.dataBufferFactory,
ResolvableType.forClass(Pojo.class), new MimeType("text", "event-stream"));
TestSubscriber
.subscribe(output)
.assertNoError()
.assertValuesWith(
stringConsumer("data:{\"foo\":\"foofoo\",\"bar\":\"barbar\"}\n"),
stringConsumer("\n"),
stringConsumer("data:{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}\n"),
stringConsumer("\n")
);
}
}

View File

@@ -0,0 +1,266 @@
/*
* Copyright 2002-2016 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.reactive.result.method.annotation;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.junit.runners.Parameterized;
import static org.springframework.web.client.reactive.HttpRequestBuilders.get;
import static org.springframework.web.client.reactive.WebResponseExtractors.bodyStream;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.test.TestSubscriber;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.codec.support.ByteBufferDecoder;
import org.springframework.core.codec.support.JacksonJsonDecoder;
import org.springframework.core.codec.support.JsonObjectDecoder;
import org.springframework.core.codec.support.StringDecoder;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.core.convert.support.ReactiveStreamsToCompletableFutureConverter;
import org.springframework.core.convert.support.ReactiveStreamsToRxJava1Converter;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorHttpClientRequestFactory;
import org.springframework.http.converter.reactive.HttpMessageConverter;
import org.springframework.http.converter.reactive.SseHttpMessageConverter;
import org.springframework.http.server.reactive.AbstractHttpHandlerIntegrationTests;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.boot.JettyHttpServer;
import org.springframework.http.server.reactive.boot.ReactorHttpServer;
import org.springframework.http.server.reactive.boot.RxNettyHttpServer;
import org.springframework.http.server.reactive.boot.TomcatHttpServer;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.reactive.WebClient;
import org.springframework.web.reactive.DispatcherHandler;
import org.springframework.web.reactive.result.SimpleResultHandler;
import org.springframework.web.reactive.sse.SseEvent;
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
/**
* @author Sebastien Deleuze
*/
public class SseIntegrationTests extends AbstractHttpHandlerIntegrationTests {
// TODO Fix Undertow support and remove this method
@Parameterized.Parameters(name = "server [{0}]")
public static Object[][] arguments() {
return new Object[][] {
{new JettyHttpServer()},
{new RxNettyHttpServer()},
{new ReactorHttpServer()},
{new TomcatHttpServer()},
};
}
private AnnotationConfigApplicationContext wac;
private WebClient webClient;
@Before
public void setup() throws Exception {
super.setup();
this.webClient = new WebClient(new ReactorHttpClientRequestFactory());
this.webClient.setMessageDecoders(Arrays.asList(
new ByteBufferDecoder(),
new StringDecoder(false),
new JacksonJsonDecoder(new JsonObjectDecoder())));
}
@Override
protected HttpHandler createHttpHandler() {
this.wac = new AnnotationConfigApplicationContext();
this.wac.register(TestConfiguration.class);
this.wac.refresh();
DispatcherHandler webHandler = new DispatcherHandler();
webHandler.setApplicationContext(this.wac);
return WebHttpHandlerBuilder.webHandler(webHandler).build();
}
@Test
public void sseAsString() throws Exception {
Mono<String> result = this.webClient
.perform(get("http://localhost:" + port + "/sse/string")
.accept(new MediaType("text", "event-stream")))
.extract(bodyStream(String.class))
.take(Duration.ofMillis(500))
.reduce((s1, s2) -> s1 + s2);
TestSubscriber
.subscribe(result)
.await()
.assertValues("data:foo 0\n\ndata:foo 1\n\n");
}
@Test
public void sseAsPojo() throws Exception {
Mono<String> result = this.webClient
.perform(get("http://localhost:" + port + "/sse/person")
.accept(new MediaType("text", "event-stream")))
.extract(bodyStream(String.class))
.take(Duration.ofMillis(500))
.reduce((s1, s2) -> s1 + s2);
TestSubscriber
.subscribe(result)
.await()
.assertValues("data:{\"name\":\"foo 0\"}\n\ndata:{\"name\":\"foo 1\"}\n\n");
}
@Test
public void sseAsEvent() throws Exception {
Mono<String> result = this.webClient
.perform(get("http://localhost:" + port + "/sse/event")
.accept(new MediaType("text", "event-stream")))
.extract(bodyStream(String.class))
.take(Duration.ofMillis(500))
.reduce((s1, s2) -> s1 + s2);
TestSubscriber
.subscribe(result)
.await()
.assertValues("id:0\n:bar\ndata:foo\n\nid:1\n:bar\ndata:foo\n\n");
}
@RestController
@SuppressWarnings("unused")
static class SseController {
@RequestMapping("/sse/string")
Flux<String> string() {
return Flux.interval(Duration.ofMillis(100)).map(l -> "foo " + l).take(2);
}
@RequestMapping("/sse/person")
Flux<Person> person() {
return Flux.interval(Duration.ofMillis(100)).map(l -> new Person("foo " + l)).take(2);
}
@RequestMapping("/sse/event")
Flux<SseEvent> sse() {
return Flux.interval(Duration.ofMillis(100)).map(l -> {
SseEvent event = new SseEvent();
event.setId(Long.toString(l));
event.setData("foo");
event.setComment("bar");
return event;
}).take(2);
}
}
@Configuration
@SuppressWarnings("unused")
static class TestConfiguration {
private DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
@Bean
public SseController sseController() {
return new SseController();
}
@Bean
public RequestMappingHandlerMapping handlerMapping() {
return new RequestMappingHandlerMapping();
}
@Bean
public RequestMappingHandlerAdapter handlerAdapter() {
RequestMappingHandlerAdapter handlerAdapter = new RequestMappingHandlerAdapter();
handlerAdapter.setConversionService(conversionService());
return handlerAdapter;
}
@Bean
public ConversionService conversionService() {
GenericConversionService service = new GenericConversionService();
service.addConverter(new ReactiveStreamsToCompletableFutureConverter());
service.addConverter(new ReactiveStreamsToRxJava1Converter());
return service;
}
@Bean
public ResponseBodyResultHandler responseBodyResultHandler() {
List<HttpMessageConverter<?>> converters = Arrays.asList(new SseHttpMessageConverter());
return new ResponseBodyResultHandler(converters, conversionService());
}
@Bean
public SimpleResultHandler simpleHandlerResultHandler() {
return new SimpleResultHandler(conversionService());
}
}
private static class Person {
private String name;
@SuppressWarnings("unused")
public Person() {
}
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Person person = (Person) o;
return !(this.name != null ? !this.name.equals(person.name) : person.name != null);
}
@Override
public int hashCode() {
return this.name != null ? this.name.hashCode() : 0;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
'}';
}
}
}