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 com.soklet.Soklet.DefaultSimulator;
020import com.soklet.Soklet.MockServerSentEventUnicaster;
021import org.jspecify.annotations.NonNull;
022import org.jspecify.annotations.Nullable;
023
024import javax.annotation.concurrent.ThreadSafe;
025import java.util.List;
026import java.util.Objects;
027import java.util.Optional;
028import java.util.concurrent.CopyOnWriteArrayList;
029import java.util.concurrent.atomic.AtomicReference;
030import java.util.concurrent.locks.ReentrantLock;
031import java.util.function.Consumer;
032
033import static java.lang.String.format;
034import static java.util.Objects.requireNonNull;
035
036/**
037 * Sealed interface used by {@link Simulator#performServerSentEventRequest(Request)} during integration tests, which encapsulates the 3 logical outcomes for SSE connections: accepted handshake, rejected handshake, and general request failure.
038 * <p>
039 * See <a href="https://www.soklet.com/docs/testing#integration-testing">https://www.soklet.com/docs/testing#integration-testing</a> for detailed documentation.
040 *
041 * @author <a href="https://www.revetkn.com">Mark Allen</a>
042 */
043public sealed interface ServerSentEventRequestResult permits ServerSentEventRequestResult.HandshakeAccepted, ServerSentEventRequestResult.HandshakeRejected, ServerSentEventRequestResult.RequestFailed {
044        /**
045         * Represents the result of an SSE accepted handshake (connection stays open) when simulated by {@link Simulator#performServerSentEventRequest(Request)}.
046         * <p>
047         * The {@link #registerEventConsumer(Consumer)} and {@link #registerCommentConsumer(Consumer)} methods can be used to "listen" for Server-Sent Events and Comments, respectively.
048         * <p>
049         * The data provided when the handshake was accepted is available via {@link #getHandshakeResult()}, and the final data sent to the client is available via {@link #getRequestResult()}.
050         */
051        @ThreadSafe
052        final class HandshakeAccepted implements ServerSentEventRequestResult {
053                private final HandshakeResult.@NonNull Accepted handshakeResult;
054                @NonNull
055                private final ResourcePath resourcePath;
056                @NonNull
057                private final RequestResult requestResult;
058                @NonNull
059                private final DefaultSimulator simulator;
060                @NonNull
061                private final AtomicReference<@Nullable Consumer<Throwable>> unicastErrorHandler;
062                @NonNull
063                private List<@NonNull ServerSentEvent> clientInitializerEvents;
064                @NonNull
065                private List<@NonNull ServerSentEventComment> clientInitializerComments;
066                @NonNull
067                private final ReentrantLock lock;
068                @Nullable
069                private Consumer<ServerSentEvent> eventConsumer;
070                @Nullable
071                private Consumer<ServerSentEventComment> commentConsumer;
072
073                HandshakeAccepted(HandshakeResult.@NonNull Accepted handshakeResult,
074                                                                                        @NonNull ResourcePath resourcePath,
075                                                                                        @NonNull RequestResult requestResult,
076                                                                                        @NonNull DefaultSimulator simulator,
077                                                                                        @Nullable Consumer<ServerSentEventUnicaster> clientInitializer) {
078                        requireNonNull(handshakeResult);
079                        requireNonNull(resourcePath);
080                        requireNonNull(requestResult);
081                        requireNonNull(simulator);
082
083                        this.handshakeResult = handshakeResult;
084                        this.resourcePath = resourcePath;
085                        this.requestResult = requestResult;
086                        this.simulator = simulator;
087                        this.unicastErrorHandler = simulator.getServerSentEventServer()
088                                        .map(serverSentEventServer -> serverSentEventServer.getUnicastErrorHandler())
089                                        .orElseGet(AtomicReference::new);
090                        this.eventConsumer = null;
091                        this.commentConsumer = null;
092                        this.lock = new ReentrantLock();
093
094                        this.clientInitializerEvents = new CopyOnWriteArrayList<>();
095                        this.clientInitializerComments = new CopyOnWriteArrayList<>();
096
097                        if (clientInitializer != null) {
098                                clientInitializer.accept(new MockServerSentEventUnicaster(
099                                                getResourcePath(),
100                                                (serverSentEvent) -> {
101                                                        requireNonNull(serverSentEvent);
102
103                                                        // If we don't have an event consumer registered, collect the events in a list to be fired off once the consumer is registered.
104                                                        // If we do have the event consumer registered, send immediately
105                                                        Consumer<ServerSentEvent> eventConsumer = getEventConsumer().orElse(null);
106
107                                                        if (eventConsumer == null)
108                                                                clientInitializerEvents.add(serverSentEvent);
109                                                        else {
110                                                                try {
111                                                                        eventConsumer.accept(serverSentEvent);
112                                                                } catch (Throwable throwable) {
113                                                                        handleUnicastError(throwable);
114                                                                }
115                                                        }
116                                                },
117                                                (serverSentEventComment) -> {
118                                                        requireNonNull(serverSentEventComment);
119
120                                                        // If we don't have an event consumer registered, collect the events in a list to be fired off once the consumer is registered.
121                                                        // If we do have the event consumer registered, send immediately
122                                                        Consumer<ServerSentEventComment> commentConsumer = getCommentConsumer().orElse(null);
123
124                                                        if (commentConsumer == null)
125                                                                clientInitializerComments.add(serverSentEventComment);
126                                                        else {
127                                                                try {
128                                                                        commentConsumer.accept(serverSentEventComment);
129                                                                } catch (Throwable throwable) {
130                                                                        handleUnicastError(throwable);
131                                                                }
132                                                        }
133                                                },
134                                                getUnicastErrorHandler())
135                                );
136                        }
137                }
138
139                /**
140                 * Registers a {@link ServerSentEvent} "consumer" for this connection - similar to how a real client would listen for Server-Sent Events.
141                 * <p>
142                 * Each connection may have at most 1 event consumer.
143                 * <p>
144                 * See documentation at <a href="https://www.soklet.com/docs/testing#server-sent-events">https://www.soklet.com/docs/testing#server-sent-events</a>.
145                 *
146                 * @param eventConsumer function to be invoked when a Server-Sent Event has been unicast/broadcast on the Resource Path
147                 * @throws IllegalStateException if you attempt to register more than 1 event consumer
148                 */
149                public void registerEventConsumer(@NonNull Consumer<ServerSentEvent> eventConsumer) {
150                        requireNonNull(eventConsumer);
151
152                        getLock().lock();
153
154                        try {
155                                if (getEventConsumer().isPresent())
156                                        throw new IllegalStateException(format("You cannot specify more than one event consumer for the same %s", HandshakeAccepted.class.getSimpleName()));
157
158                                this.eventConsumer = eventConsumer;
159
160                                // Send client initializer unicast events immediately, before any broadcasts can make it through
161                                for (ServerSentEvent event : getClientInitializerEvents()) {
162                                        try {
163                                                eventConsumer.accept(event);
164                                        } catch (Throwable throwable) {
165                                                handleUnicastError(throwable);
166                                        }
167                                }
168
169                                // Register with the mock SSE server broadcaster, preserving client context
170                                Object clientContext = getHandshakeResult().getClientContext().orElse(null);
171                                getSimulator().getServerSentEventServer().get().registerEventConsumer(getResourcePath(), eventConsumer, clientContext);
172                        } finally {
173                                getLock().unlock();
174                        }
175                }
176
177                /**
178                 * Registers a Server-Sent comment "consumer" for this connection - similar to how a real client would listen for Server-Sent comment payloads.
179                 * <p>
180                 * Each connection may have at most 1 comment consumer.
181                 * <p>
182                 * See documentation at <a href="https://www.soklet.com/docs/testing#server-sent-events">https://www.soklet.com/docs/testing#server-sent-events</a>.
183                 *
184                 * @param commentConsumer function to be invoked when a Server-Sent comment has been unicast/broadcast on the Resource Path
185                 * @throws IllegalStateException if you attempt to register more than 1 comment consumer
186                 */
187                public void registerCommentConsumer(@NonNull Consumer<ServerSentEventComment> commentConsumer) {
188                        requireNonNull(commentConsumer);
189
190                        getLock().lock();
191
192                        try {
193                                if (getCommentConsumer().isPresent())
194                                        throw new IllegalStateException(format("You cannot specify more than one comment consumer for the same %s", HandshakeAccepted.class.getSimpleName()));
195
196                                this.commentConsumer = commentConsumer;
197
198                                // Send client initializer unicast comments immediately, before any broadcasts can make it through
199                                for (ServerSentEventComment comment : getClientInitializerComments()) {
200                                        try {
201                                                commentConsumer.accept(comment);
202                                        } catch (Throwable throwable) {
203                                                handleUnicastError(throwable);
204                                        }
205                                }
206
207                                // Register with the mock SSE server broadcaster, preserving client context
208                                Object clientContext = getHandshakeResult().getClientContext().orElse(null);
209                                getSimulator().getServerSentEventServer().get().registerCommentConsumer(getResourcePath(), commentConsumer, clientContext);
210                        } finally {
211                                getLock().unlock();
212                        }
213                }
214
215                void unregisterConsumers() {
216                        getLock().lock();
217
218                        try {
219                                getEventConsumer().ifPresent((eventConsumer ->
220                                                getSimulator().getServerSentEventServer().get().unregisterEventConsumer(getResourcePath(), eventConsumer)));
221
222                                getCommentConsumer().ifPresent((commentConsumer ->
223                                                getSimulator().getServerSentEventServer().get().unregisterCommentConsumer(getResourcePath(), commentConsumer)));
224                        } finally {
225                                getLock().unlock();
226                        }
227                }
228
229                /**
230                 * Gets the data provided when the handshake was accepted by the {@link com.soklet.annotation.ServerSentEventSource}-annotated <em>Resource Method</em>.
231                 *
232                 * @return the data provided when the handshake was accepted
233                 */
234                public HandshakeResult.@NonNull Accepted getHandshakeResult() {
235                        return this.handshakeResult;
236                }
237
238                @Override
239                public String toString() {
240                        return format("%s{handshakeResult=%s}", HandshakeAccepted.class.getSimpleName(), getHandshakeResult());
241                }
242
243                /**
244                 * The initial result of the handshake, as written back to the client (note that the connection remains open).
245                 * <p>
246                 * Useful for examining headers/cookies written via {@link RequestResult#getMarshaledResponse()}.
247                 *
248                 * @return the result of this request
249                 */
250                @NonNull
251                public RequestResult getRequestResult() {
252                        return this.requestResult;
253                }
254
255                @NonNull
256                private ResourcePath getResourcePath() {
257                        return this.resourcePath;
258                }
259
260                @NonNull
261                private DefaultSimulator getSimulator() {
262                        return this.simulator;
263                }
264
265                @NonNull
266                private AtomicReference<@Nullable Consumer<Throwable>> getUnicastErrorHandler() {
267                        return this.unicastErrorHandler;
268                }
269
270                private void handleUnicastError(@NonNull Throwable throwable) {
271                        requireNonNull(throwable);
272                        Consumer<Throwable> handler = getUnicastErrorHandler().get();
273
274                        if (handler != null) {
275                                try {
276                                        handler.accept(throwable);
277                                        return;
278                                } catch (Throwable ignored) {
279                                        // Fall through to default behavior
280                                }
281                        }
282
283                        throwable.printStackTrace();
284                }
285
286                @NonNull
287                private List<@NonNull ServerSentEvent> getClientInitializerEvents() {
288                        return this.clientInitializerEvents;
289                }
290
291                @NonNull
292                private List<@NonNull ServerSentEventComment> getClientInitializerComments() {
293                        return this.clientInitializerComments;
294                }
295
296                @NonNull
297                private Optional<Consumer<ServerSentEvent>> getEventConsumer() {
298                        return Optional.ofNullable(this.eventConsumer);
299                }
300
301                @NonNull
302                private Optional<Consumer<ServerSentEventComment>> getCommentConsumer() {
303                        return Optional.ofNullable(this.commentConsumer);
304                }
305
306                @NonNull
307                private ReentrantLock getLock() {
308                        return this.lock;
309                }
310        }
311
312        /**
313         * Represents the result of an SSE rejected handshake (explicit rejection; connection closed) when simulated by {@link Simulator#performServerSentEventRequest(Request)}.
314         * <p>
315         * The data provided when the handshake was rejected is available via {@link #getHandshakeResult()}, and the final data sent to the client is available via {@link #getRequestResult()}.
316         */
317        @ThreadSafe
318        final class HandshakeRejected implements ServerSentEventRequestResult {
319                private final HandshakeResult.@NonNull Rejected handshakeResult;
320                @NonNull
321                private final RequestResult requestResult;
322
323                HandshakeRejected(HandshakeResult.@NonNull Rejected handshakeResult,
324                                                                                        @NonNull RequestResult requestResult) {
325                        requireNonNull(handshakeResult);
326                        requireNonNull(requestResult);
327
328                        this.handshakeResult = handshakeResult;
329                        this.requestResult = requestResult;
330                }
331
332                /**
333                 * Gets the data provided when the handshake was explicitly rejected by the {@link com.soklet.annotation.ServerSentEventSource}-annotated <em>Resource Method</em>.
334                 *
335                 * @return the data provided when the handshake was rejected
336                 */
337                public HandshakeResult.@NonNull Rejected getHandshakeResult() {
338                        return this.handshakeResult;
339                }
340
341                /**
342                 * The result of the handshake, as written back to the client (the connection is then closed).
343                 *
344                 * @return the result of this request
345                 */
346                @NonNull
347                public RequestResult getRequestResult() {
348                        return this.requestResult;
349                }
350
351                @Override
352                public String toString() {
353                        return format("%s{handshakeResult=%s, requestResult=%s}", HandshakeRejected.class.getSimpleName(), getHandshakeResult(), getRequestResult());
354                }
355
356                @Override
357                public boolean equals(@Nullable Object object) {
358                        if (this == object)
359                                return true;
360
361                        if (!(object instanceof HandshakeRejected handshakeRejected))
362                                return false;
363
364                        return Objects.equals(getHandshakeResult(), handshakeRejected.getHandshakeResult())
365                                        && Objects.equals(getRequestResult(), handshakeRejected.getRequestResult());
366                }
367
368                @Override
369                public int hashCode() {
370                        return Objects.hash(getHandshakeResult(), getRequestResult());
371                }
372        }
373
374        /**
375         * Represents the result of an SSE request failure (implicit rejection, e.g. an exception occurred; connection closed) when simulated by {@link Simulator#performServerSentEventRequest(Request)}.
376         * <p>
377         * The final data sent to the client is available via {@link #getRequestResult()}.
378         */
379        @ThreadSafe
380        final class RequestFailed implements ServerSentEventRequestResult {
381                @NonNull
382                private final RequestResult requestResult;
383
384                RequestFailed(@NonNull RequestResult requestResult) {
385                        requireNonNull(requestResult);
386                        this.requestResult = requestResult;
387                }
388
389                /**
390                 * The result of the handshake, as written back to the client (the connection is then closed).
391                 *
392                 * @return the result of this request
393                 */
394                @NonNull
395                public RequestResult getRequestResult() {
396                        return this.requestResult;
397                }
398
399                @Override
400                public String toString() {
401                        return format("%s{requestResult=%s}", RequestFailed.class.getSimpleName(), getRequestResult());
402                }
403
404                @Override
405                public boolean equals(@Nullable Object object) {
406                        if (this == object)
407                                return true;
408
409                        if (!(object instanceof RequestFailed requestFailed))
410                                return false;
411
412                        return Objects.equals(getRequestResult(), requestFailed.getRequestResult());
413                }
414
415                @Override
416                public int hashCode() {
417                        return Objects.hash(getRequestResult());
418                }
419        }
420}