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