
fi.evolver.basics.spring.http.SseSubscriber Maven / Gradle / Ivy
package fi.evolver.basics.spring.http;
import java.net.http.HttpClient;
import java.net.http.HttpResponse.BodyHandler;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Flow.Subscriber;
import java.util.concurrent.Flow.Subscription;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A subscriber (and {@link BodyHandler} provider) for HTTP server-side event streams.
*/
public class SseSubscriber implements Subscriber {
private static final Logger LOG = LoggerFactory.getLogger(SseSubscriber.class);
private static final Pattern REGEX = Pattern.compile("(?[^\\r\\n:]*): (?.*)");
private final SseEventConsumer consumer;
private final StringBuilder builder = new StringBuilder();
private Map> customFields = new LinkedHashMap<>();
private String id;
private String event;
private Integer retry;
private SseSubscriber(SseEventConsumer consumer) {
this.consumer = consumer;
}
@Override
public void onSubscribe(Subscription subscription) {
subscription.request(Long.MAX_VALUE);
}
@Override
public void onNext(String line) {
if (line.isEmpty()) {
publishEvent();
return;
}
Matcher matcher = REGEX.matcher(line);
if (!matcher.find()) {
LOG.warn("Invalid SSE row: '{}'", line);
return;
}
String type = matcher.group("type");
String value = matcher.group("value");
switch (type) {
case "" -> { /* comment */ }
case "id" -> id = value;
case "event" -> event = value;
case "retry" -> { if (value.matches("\\d+")) retry = Integer.parseInt(value); }
case "data" -> builder.append(value).append('\n');
default -> customFields.computeIfAbsent(type, k -> new ArrayList<>()).add(value);
}
}
@Override
public void onError(Throwable throwable) {
consumer.onError(throwable);
}
@Override
public void onComplete() {
publishEvent();
consumer.onComplete();
}
private void publishEvent() {
SseEvent sseEvent = new SseEvent(
id,
event,
builder.toString(),
retry,
customFields);
id = null;
event = null;
builder.setLength(0);
retry = null;
customFields = new LinkedHashMap<>();
if (!sseEvent.isEmpty()) {
try {
consumer.onEvent(sseEvent);
}
catch (RuntimeException e) {
LOG.error("Consumer failed handling SseEvent", e);
}
}
}
/**
* Creates a {@link BodyHandler} for using with {@link HttpClient}.
*
* @param consumer A consumer for the SSE stream.
* @return A body handler which produces no response body.
*/
public static BodyHandler createBodyHandler(SseEventConsumer consumer) {
return BodyHandlers.fromLineSubscriber(new SseSubscriber(consumer));
}
/**
* Details about a single server-side event.
*/
public static record SseEvent(
String id,
String event,
String data,
Integer retry,
Map> customFields) {
private boolean isEmpty() {
return data.isEmpty() && id == null && event == null && retry == null && customFields.isEmpty();
}
}
public static interface SseEventConsumer {
/**
* Handle a single SSE.
*
* @param event The server-side event to handle.
*/
void onEvent(SseEvent event);
/**
* Called when the event stream has completed.
*/
default void onComplete() { }
/**
* Called on error. By default logs the error.
*
* @param throwable The throwable detailing the issue.
*/
default void onError(Throwable throwable) {
LOG.error("Error while handling server-side event", throwable);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy