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}