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