All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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