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.HandshakeResult.Accepted.Builder;
020import com.soklet.internal.spring.LinkedCaseInsensitiveMap;
021
022import javax.annotation.Nonnull;
023import javax.annotation.Nullable;
024import javax.annotation.concurrent.NotThreadSafe;
025import javax.annotation.concurrent.ThreadSafe;
026import java.util.Collections;
027import java.util.LinkedHashSet;
028import java.util.Map;
029import java.util.Objects;
030import java.util.Optional;
031import java.util.Set;
032import java.util.function.Consumer;
033
034import static java.lang.String.format;
035import static java.util.Objects.requireNonNull;
036
037/**
038 * Represents the result of a {@link com.soklet.annotation.ServerSentEventSource} "handshake".
039 * <p>
040 * Once a handshake has been accepted, you may acquire a broadcaster via {@link ServerSentEventServer#acquireBroadcaster(ResourcePath)} - the client whose handshake was accepted will then receive Server-Sent Events broadcast via {@link ServerSentEventBroadcaster#broadcastEvent(ServerSentEvent)}.
041 * <p>
042 * You might have a JavaScript Server-Sent Event client that looks like this:
043 * <pre>{@code // Register an event source
044 * let eventSourceUrl =
045 *   'https://sse.example.com/chats/123/event-source?signingToken=xxx';
046 *
047 * let eventSource = new EventSource(eventSourceUrl);
048 *
049 * // Listen for Server-Sent Events
050 * eventSource.addEventListener('chat-message', (e) => {
051 *   console.log(`Chat message: ${e.data}`);
052 * });}</pre>
053 * <p>
054 * And then a Soklet Server-Sent Event Source that looks like this, which performs the handshake:
055 * <pre>{@code // Resource Method that acts as a Server-Sent Event Source
056 * @ServerSentEventSource("/chats/{chatId}/event-source")
057 * public HandshakeResult chatEventSource(
058 *   @PathParameter Long chatId,
059 *   @QueryParameter String signingToken
060 * ) {
061 *   Chat chat = myChatService.find(chatId);
062 *
063 *   // Exceptions that bubble out will reject the handshake and go through the
064 *   // ResponseMarshaler.forThrowable(...) path, same as non-SSE Resource Methods
065 *   if (chat == null)
066 *     throw new NotFoundException();
067 *
068 *   // You'll normally want to use a transient signing token for SSE authorization
069 *   myAuthorizationService.verify(signingToken);
070 *
071 *   // Accept the handshake with no additional data
072 *   // (or use a variant to send headers/cookies).
073 *   // Can also reject via HandshakeResult.rejectWithResponse(...)
074 *   return HandshakeResult.accept();
075 * }}</pre>
076 * <p>
077 * Finally, broadcast to all clients who had their handshakes accepted:
078 * <pre>{@code // Sometime later, acquire a broadcaster...
079 * ResourcePath resourcePath = ResourcePath.withPath("/chats/123/event-source");
080 * ServerSentEventBroadcaster broadcaster = sseServer.acquireBroadcaster(resourcePath).get();
081 *
082 * // ...construct the payload...
083 * ServerSentEvent event = ServerSentEvent.withEvent("chat-message")
084 *   .data("Hello, world") // often JSON
085 *   .retry(Duration.ofSeconds(5))
086 *   .build();
087 *
088 * // ...and send it to all connected clients.
089 * broadcaster.broadcastEvent(event);}</pre>
090 * <p>
091 * Full documentation is available at <a href="https://www.soklet.com/docs/server-sent-events">https://www.soklet.com/docs/server-sent-events</a>.
092 *
093 * @author <a href="https://www.revetkn.com">Mark Allen</a>
094 */
095public sealed interface HandshakeResult permits HandshakeResult.Accepted, HandshakeResult.Rejected {
096        /**
097         * Vends an instance that indicates a successful handshake, with no additional information provided.
098         *
099         * @return an instance that indicates a successful handshake
100         */
101        @Nonnull
102        static Accepted accept() {
103                return Accepted.DEFAULT_INSTANCE;
104        }
105
106        /**
107         * Vends a builder for an instance that indicates a successful handshake.
108         * <p>
109         * The builder supports specifying optional response headers, cookies, and a post-handshake client initialization hook, which is useful to "catch up" in a {@code Last-Event-ID} handshake scenario.
110         *
111         * @return a builder for an instance that indicates a successful handshake
112         */
113        @Nonnull
114        static Builder acceptWithDefaults() {
115                return new Builder();
116        }
117
118        /**
119         * Vends an instance that indicates a rejected handshake along with a logical response to send to the client.
120         *
121         * @param response the response to send to the client
122         * @return an instance that indicates a rejected handshake
123         */
124        @Nonnull
125        static Rejected rejectWithResponse(@Nonnull Response response) {
126                requireNonNull(response);
127                return new Rejected(response);
128        }
129
130        /**
131         * Type which indicates a successful Server-Sent Event handshake.
132         * <p>
133         * A default, no-customization-permitted instance can be acquired via {@link #accept()} and a builder which enables customization can be acquired via {@link #acceptWithDefaults()}.
134         * <p>
135         * Full documentation is available at <a href="https://www.soklet.com/docs/server-sent-events#accepting-handshakes">https://www.soklet.com/docs/server-sent-events#accepting-handshakes</a>.
136         *
137         * @author <a href="https://www.revetkn.com">Mark Allen</a>
138         */
139        @ThreadSafe
140        final class Accepted implements HandshakeResult {
141                @Nonnull
142                static final Accepted DEFAULT_INSTANCE;
143
144                static {
145                        DEFAULT_INSTANCE = new Builder().build();
146                }
147
148                /**
149                 * Builder used to construct instances of {@link Accepted}.
150                 * <p>
151                 * This class is intended for use by a single thread.
152                 *
153                 * @author <a href="https://www.revetkn.com">Mark Allen</a>
154                 */
155                @NotThreadSafe
156                public static final class Builder {
157                        @Nullable
158                        private Map<String, Set<String>> headers;
159                        @Nullable
160                        private Set<ResponseCookie> cookies;
161                        @Nullable
162                        private Consumer<ServerSentEventUnicaster> clientInitializer;
163
164                        private Builder() {
165                                // Only permit construction through Handshake builder methods
166                        }
167
168                        /**
169                         * Specifies custom response headers to be sent with the handshake.
170                         *
171                         * @param headers custom response headers to send
172                         * @return this builder, for chaining
173                         */
174                        @Nonnull
175                        public Builder headers(@Nullable Map<String, Set<String>> headers) {
176                                this.headers = headers;
177                                return this;
178                        }
179
180                        /**
181                         * Specifies custom response cookies to be sent with the handshake.
182                         *
183                         * @param cookies custom response cookies to send
184                         * @return this builder, for chaining
185                         */
186                        @Nonnull
187                        public Builder cookies(@Nullable Set<ResponseCookie> cookies) {
188                                this.cookies = cookies;
189                                return this;
190                        }
191
192                        /**
193                         * Specifies custom "client initializer" function to run immediately after the handshake succeeds - useful for performing "catch-up" logic if the client had provided a {@code Last-Event-ID} request header.
194                         * <p>
195                         * The function is provided with a {@link ServerSentEventUnicaster}, which permits sending Server-Sent Events and comments directly to the client that accepted the handshake (as opposed to a {@link ServerSentEventBroadcaster}, which would send to all clients listening on the same {@link ResourcePath}).
196                         * <p>
197                         * Full documentation is available at <a href="https://www.soklet.com/docs/server-sent-events">https://www.soklet.com/docs/server-sent-events</a>.
198                         *
199                         * @param clientInitializer custom function to run to initialize the client
200                         * @return this builder, for chaining
201                         */
202                        @Nonnull
203                        public Builder clientInitializer(@Nullable Consumer<ServerSentEventUnicaster> clientInitializer) {
204                                this.clientInitializer = clientInitializer;
205                                return this;
206                        }
207
208                        @Nonnull
209                        public Accepted build() {
210                                return new Accepted(this);
211                        }
212                }
213
214                @Nullable
215                private final Map<String, Set<String>> headers;
216                @Nullable
217                private final Set<ResponseCookie> cookies;
218                @Nullable
219                private final Consumer<ServerSentEventUnicaster> clientInitializer;
220
221                private Accepted(@Nonnull Builder builder) {
222                        requireNonNull(builder);
223
224                        // Defensive copies
225                        Map<String, Set<String>> headers = builder.headers == null ? Map.of() : Collections.unmodifiableMap(new LinkedCaseInsensitiveMap<>(builder.headers));
226                        Set<ResponseCookie> cookies = builder.cookies == null ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(builder.cookies));
227
228                        this.headers = headers;
229                        this.cookies = cookies;
230                        this.clientInitializer = builder.clientInitializer;
231                }
232
233                /**
234                 * Returns the headers explicitly specified when this handshake was accepted (which may be different from the finalized map of headers sent to the client).
235                 *
236                 * @return the headers explicitly specified when this handshake was accepted
237                 */
238                @Nullable
239                public Map<String, Set<String>> getHeaders() {
240                        return this.headers;
241                }
242
243                /**
244                 * Returns the cookies explicitly specified when this handshake was accepted (which may be different from the finalized map of headers sent to the client).
245                 *
246                 * @return the cookies explicitly specified when this handshake was accepted
247                 */
248                @Nullable
249                public Set<ResponseCookie> getCookies() {
250                        return this.cookies;
251                }
252
253                /**
254                 * The client initialization function, if specified, for this accepted Server-Sent Event handshake.
255                 *
256                 * @return the client initialization function, or {@link Optional#empty()} if none was specified
257                 */
258                @Nonnull
259                public Optional<Consumer<ServerSentEventUnicaster>> getClientInitializer() {
260                        return Optional.ofNullable(this.clientInitializer);
261                }
262
263                @Override
264                public String toString() {
265                        return format("%s{headers=%s, cookies=%s, clientInitializer=%s}",
266                                        Accepted.class.getSimpleName(), getHeaders(), getCookies(), getClientInitializer().isPresent() ? "[specified]" : "[not specified]");
267                }
268
269                @Override
270                public boolean equals(@Nullable Object object) {
271                        if (this == object)
272                                return true;
273
274                        if (!(object instanceof Accepted accepted))
275                                return false;
276
277                        return Objects.equals(getHeaders(), accepted.getHeaders())
278                                        && Objects.equals(getCookies(), accepted.getCookies())
279                                        && Objects.equals(getClientInitializer(), accepted.getClientInitializer());
280                }
281
282                @Override
283                public int hashCode() {
284                        return Objects.hash(getHeaders(), getCookies(), getClientInitializer());
285                }
286        }
287
288        /**
289         * Type which indicates a rejected Server-Sent Event handshake.
290         * <p>
291         * Instances can be acquired via the {@link HandshakeResult#rejectWithResponse(Response)} factory method.
292         * <p>
293         * Full documentation is available at <a href="https://www.soklet.com/docs/server-sent-events#rejecting-handshakes">https://www.soklet.com/docs/server-sent-events#rejecting-handshakes</a>.
294         *
295         * @author <a href="https://www.revetkn.com">Mark Allen</a>
296         */
297        @ThreadSafe
298        final class Rejected implements HandshakeResult {
299                @Nonnull
300                private final Response response;
301
302                private Rejected(@Nonnull Response response) {
303                        requireNonNull(response);
304                        this.response = response;
305                }
306
307                /**
308                 * The logical response to send to the client for this handshake rejection.
309                 *
310                 * @return the logical response for this handshake rejection
311                 */
312                @Nonnull
313                public Response getResponse() {
314                        return this.response;
315                }
316
317                @Override
318                public String toString() {
319                        return format("%s{response=%s}", Rejected.class.getSimpleName(), getResponse());
320                }
321
322                @Override
323                public boolean equals(@Nullable Object object) {
324                        if (this == object)
325                                return true;
326
327                        if (!(object instanceof Rejected rejected))
328                                return false;
329
330                        return Objects.equals(getResponse(), rejected.getResponse());
331                }
332
333                @Override
334                public int hashCode() {
335                        return Objects.hash(getResponse());
336                }
337        }
338}