001/* 002 * Copyright 2022-2025 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import javax.annotation.Nonnull; 020import javax.annotation.Nullable; 021import javax.annotation.concurrent.NotThreadSafe; 022import javax.annotation.concurrent.ThreadSafe; 023import java.time.Duration; 024import java.util.ArrayList; 025import java.util.List; 026import java.util.Optional; 027import java.util.stream.Collectors; 028 029import static java.lang.String.format; 030import static java.util.Objects.requireNonNull; 031 032/** 033 * Encapsulates a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events">Server-Sent Event</a> payload that can be sent across the wire to a client. 034 * <p> 035 * For example: 036 * <pre>{@code ServerSentEvent event = ServerSentEvent.withEvent("example") 037 * .data(""" 038 * { 039 * "testing": 123, 040 * "value": "abc" 041 * } 042 * """) 043 * .id(UUID.randomUUID().toString()) 044 * .retry(Duration.ofSeconds(5)) 045 * .build();}</pre> 046 * <p> 047 * Threadsafe instances can be acquired via these builder factory methods: 048 * <ul> 049 * <li>{@link #withEvent(String)} (builder primed with an event value)</li> 050 * <li>{@link #withData(String)} (builder primed with a data value)</li> 051 * <li>{@link #withDefaults()} ("empty" builder suitable for constructing special cases like {@code retry}-only or {@code id}-only events.)</li> 052 * </ul> 053 * <p> 054 * See <a href="https://www.soklet.com/docs/server-sent-events">https://www.soklet.com/docs/server-sent-events</a> for detailed documentation. 055 * <p> 056 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 057 * 058 * @author <a href="https://www.revetkn.com">Mark Allen</a> 059 */ 060@ThreadSafe 061public final class ServerSentEvent { 062 @Nullable 063 private final String id; 064 @Nullable 065 private final String event; 066 @Nullable 067 private final String data; 068 @Nullable 069 private final Duration retry; 070 071 /** 072 * Acquires a builder for {@link ServerSentEvent} instances, seeded with an {@code event} value. 073 * 074 * @param event the {@code event} value for the instance 075 * @return the builder 076 */ 077 @Nonnull 078 public static Builder withEvent(@Nullable String event) { 079 return new Builder().event(event); 080 } 081 082 /** 083 * Acquires a builder for {@link ServerSentEvent} instances, seeded with a {@code data} value. 084 * 085 * @param data the {@code data} value for the instance 086 * @return the builder 087 */ 088 @Nonnull 089 public static Builder withData(@Nullable String data) { 090 return new Builder().data(data); 091 } 092 093 /** 094 * Acquires an "empty" builder for {@link ServerSentEvent} instances, useful for creating special cases like {@code retry}-only or {@code id}-only events. 095 * 096 * @return the builder 097 */ 098 @Nonnull 099 public static Builder withDefaults() { 100 return new Builder(); 101 } 102 103 protected ServerSentEvent(@Nonnull Builder builder) { 104 requireNonNull(builder); 105 106 this.id = builder.id; 107 this.event = builder.event; 108 this.data = builder.data; 109 this.retry = builder.retry; 110 111 // Ensure legal construction 112 113 if (this.retry != null && this.retry.isNegative()) 114 throw new IllegalArgumentException(format("%s 'retry' values must be non-negative. You supplied '%s'", 115 ServerSentEvent.class.getSimpleName(), this.retry)); 116 117 if (this.event != null && containsLineBreaks(this.event)) 118 throw new IllegalArgumentException(format("%s 'event' values must not contain CR or LF characters. You supplied '%s'", 119 ServerSentEvent.class.getSimpleName(), Utilities.printableString(this.event))); 120 121 if (this.id != null && (containsLineBreaks(this.id) || this.id.contains("\u0000"))) 122 throw new IllegalArgumentException(format("%s 'id' values must not contain NUL (\\u0000), CR, or LF characters. You supplied '%s'", 123 ServerSentEvent.class.getSimpleName(), Utilities.printableString(this.id))); 124 } 125 126 @Nonnull 127 private Boolean containsLineBreaks(@Nonnull String string) { 128 requireNonNull(string); 129 return string.indexOf('\n') >= 0 || string.indexOf('\r') >= 0; 130 } 131 132 /** 133 * Builder used to construct instances of {@link ServerSentEvent} via {@link ServerSentEvent#withEvent(String)}, {@link ServerSentEvent#withData(String)}, or {@link ServerSentEvent#withDefaults()}. 134 * <p> 135 * This class is intended for use by a single thread. 136 * 137 * @author <a href="https://www.revetkn.com">Mark Allen</a> 138 */ 139 @NotThreadSafe 140 public static final class Builder { 141 @Nullable 142 private String id; 143 @Nullable 144 private String event; 145 @Nullable 146 private String data; 147 @Nullable 148 private Duration retry; 149 150 protected Builder() { 151 // Nothing to do 152 } 153 154 @Nonnull 155 public Builder id(@Nullable String id) { 156 this.id = id; 157 return this; 158 } 159 160 @Nonnull 161 public Builder event(@Nullable String event) { 162 this.event = event; 163 return this; 164 } 165 166 @Nonnull 167 public Builder data(@Nullable String data) { 168 this.data = data; 169 return this; 170 } 171 172 @Nonnull 173 public Builder retry(@Nullable Duration retry) { 174 this.retry = retry; 175 return this; 176 } 177 178 @Nonnull 179 public ServerSentEvent build() { 180 return new ServerSentEvent(this); 181 } 182 } 183 184 @Override 185 @Nonnull 186 public String toString() { 187 List<String> components = new ArrayList<>(4); 188 189 if (this.event != null) 190 components.add(format("event=%s", this.event)); 191 if (this.id != null) 192 components.add(format("id=%s", this.id)); 193 if (this.retry != null) 194 components.add(format("retry=%s", this.retry)); 195 if (this.data != null) 196 components.add(format("data=%s", this.data.trim())); 197 198 return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", "))); 199 } 200 201 /** 202 * The {@code id} for this Server-Sent Event, used by clients to populate the {@code Last-Event-ID} request header should a reconnect occur. 203 * <p> 204 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 205 * 206 * @return the optional {@code id} for this Server-Sent Event 207 */ 208 @Nonnull 209 public Optional<String> getId() { 210 return Optional.ofNullable(this.id); 211 } 212 213 /** 214 * The {@code event} value for this Server-Sent Event. 215 * <p> 216 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 217 * 218 * @return the optional {@code event} value for this Server-Sent Event 219 */ 220 @Nonnull 221 public Optional<String> getEvent() { 222 return Optional.ofNullable(this.event); 223 } 224 225 /** 226 * The {@code data} payload for this Server-Sent Event. 227 * <p> 228 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 229 * 230 * @return the optional {@code data} payload for this Server-Sent Event 231 */ 232 @Nonnull 233 public Optional<String> getData() { 234 return Optional.ofNullable(this.data); 235 } 236 237 /** 238 * The {@code retry} duration for this Server-Sent Event. 239 * <p> 240 * Formal specification is available at <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events">https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events</a>. 241 * 242 * @return the optional {@code retry} duration for this Server-Sent Event 243 */ 244 @Nonnull 245 public Optional<Duration> getRetry() { 246 return Optional.ofNullable(this.retry); 247 } 248}