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.internal.spring.LinkedCaseInsensitiveMap;
020import org.jspecify.annotations.NonNull;
021import org.jspecify.annotations.Nullable;
022
023import javax.annotation.concurrent.NotThreadSafe;
024import javax.annotation.concurrent.ThreadSafe;
025import java.util.Collections;
026import java.util.LinkedHashSet;
027import java.util.Map;
028import java.util.Objects;
029import java.util.Optional;
030import java.util.Set;
031import java.util.function.Consumer;
032import java.util.function.Function;
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.fromPath("/chats/123/event-source");
080 * ServerSentEventBroadcaster broadcaster = sseServer.acquireBroadcaster(resourcePath).orElseThrow();
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         * <p>
099         * For a customizable acceptance result, use {@link HandshakeResult.Accepted#builder()}.
100         *
101         * @return an instance that indicates a successful handshake
102         */
103        @NonNull
104        static Accepted accept() {
105                return Accepted.DEFAULT_INSTANCE;
106        }
107
108        /**
109         * Vends an instance that indicates a successful handshake with a custom client context.
110         * <p>
111         * This is a convenience method equivalent to {@link HandshakeResult.Accepted#builder()}
112         * {@code .clientContext(clientContext).build()}.
113         *
114         * @param clientContext custom context to preserve for the lifetime of the SSE connection (may be {@code null})
115         * @return an instance that indicates a successful handshake with the specified client context
116         */
117        @NonNull
118        static Accepted acceptWithClientContext(@Nullable Object clientContext) {
119                return Accepted.builder()
120                        .clientContext(clientContext)
121                        .build();
122        }
123
124        /**
125         * Vends an instance that indicates a rejected handshake along with a logical response to send to the client.
126         *
127         * @param response the response to send to the client
128         * @return an instance that indicates a rejected handshake
129         */
130        @NonNull
131        static Rejected rejectWithResponse(@NonNull Response response) {
132                requireNonNull(response);
133                return new Rejected(response);
134        }
135
136        /**
137         * Type which indicates a successful Server-Sent Event handshake.
138         * <p>
139         * A default, no-customization-permitted instance can be acquired via {@link HandshakeResult#accept()} and a builder which enables customization can be acquired via {@link HandshakeResult.Accepted#builder()}.
140         * <p>
141         * 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>.
142         *
143         * @author <a href="https://www.revetkn.com">Mark Allen</a>
144         */
145        @ThreadSafe
146        final class Accepted implements HandshakeResult {
147                @NonNull
148                static final Accepted DEFAULT_INSTANCE;
149
150                static {
151                        DEFAULT_INSTANCE = new Builder().build();
152                }
153
154                /**
155                 * Vends a builder for an instance that indicates a successful handshake.
156                 * <p>
157                 * 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.
158                 *
159                 * @return a builder for an instance that indicates a successful handshake
160                 */
161                @NonNull
162                public static Builder builder() {
163                        return new Builder();
164                }
165
166                /**
167                 * Builder used to construct instances of {@link Accepted}.
168                 * <p>
169                 * This class is intended for use by a single thread.
170                 *
171                 * @author <a href="https://www.revetkn.com">Mark Allen</a>
172                 */
173                @NotThreadSafe
174                public static final class Builder {
175                        @Nullable
176                        private Map<@NonNull String, @NonNull Set<@NonNull String>> headers;
177                        @Nullable
178                        private Set<@NonNull ResponseCookie> cookies;
179                        @Nullable
180                        private Object clientContext;
181                        @Nullable
182                        private Consumer<ServerSentEventUnicaster> clientInitializer;
183
184                        private Builder() {
185                                // Only permit construction through Handshake builder methods
186                        }
187
188                        /**
189                         * Specifies custom response headers to be sent with the handshake.
190                         *
191                         * @param headers custom response headers to send
192                         * @return this builder, for chaining
193                         */
194                        @NonNull
195                        public Builder headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
196                                this.headers = headers;
197                                return this;
198                        }
199
200                        /**
201                         * Specifies custom response cookies to be sent with the handshake.
202                         *
203                         * @param cookies custom response cookies to send
204                         * @return this builder, for chaining
205                         */
206                        @NonNull
207                        public Builder cookies(@Nullable Set<@NonNull ResponseCookie> cookies) {
208                                this.cookies = cookies;
209                                return this;
210                        }
211
212                        /**
213                         * Specifies an application-specific custom context to be preserved over the lifetime of the SSE connection.
214                         * <p>
215                         * For example, an application might want to broadcast differently-formatted payloads based on the client's locale - a {@link java.util.Locale} object could be specified as client context.
216                         * <p>
217                         * Server-Sent Events can then be broadcast per-locale via {@link ServerSentEventBroadcaster#broadcastEvent(Function, Function)}.
218                         *
219                         * @param clientContext custom context
220                         * @return this builder, for chaining
221                         */
222                        @NonNull
223                        public Builder clientContext(@Nullable Object clientContext) {
224                                this.clientContext = clientContext;
225                                return this;
226                        }
227
228                        /**
229                         * 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.
230                         * <p>
231                         * 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}).
232                         * <p>
233                         * Full documentation is available at <a href="https://www.soklet.com/docs/server-sent-events">https://www.soklet.com/docs/server-sent-events</a>.
234                         *
235                         * @param clientInitializer custom function to run to initialize the client
236                         * @return this builder, for chaining
237                         */
238                        @NonNull
239                        public Builder clientInitializer(@Nullable Consumer<ServerSentEventUnicaster> clientInitializer) {
240                                this.clientInitializer = clientInitializer;
241                                return this;
242                        }
243
244                        @NonNull
245                        public Accepted build() {
246                                return new Accepted(this);
247                        }
248                }
249
250                @Nullable
251                private final Map<@NonNull String, @NonNull Set<@NonNull String>> headers;
252                @Nullable
253                private final Set<@NonNull ResponseCookie> cookies;
254                @Nullable
255                private final Object clientContext;
256                @Nullable
257                private final Consumer<ServerSentEventUnicaster> clientInitializer;
258
259                private Accepted(@NonNull Builder builder) {
260                        requireNonNull(builder);
261
262                        // Defensive copies
263                        Map<String, Set<String>> headers = builder.headers == null ? Map.of() : Collections.unmodifiableMap(new LinkedCaseInsensitiveMap<>(builder.headers));
264                        Set<ResponseCookie> cookies = builder.cookies == null ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(builder.cookies));
265
266                        this.headers = headers;
267                        this.cookies = cookies;
268                        this.clientContext = builder.clientContext;
269                        this.clientInitializer = builder.clientInitializer;
270                }
271
272                /**
273                 * Returns the headers explicitly specified when this handshake was accepted (which may be different from the finalized map of headers sent to the client).
274                 *
275                 * @return the headers explicitly specified when this handshake was accepted
276                 */
277                @Nullable
278                public Map<@NonNull String, @NonNull Set<@NonNull String>> getHeaders() {
279                        return this.headers;
280                }
281
282                /**
283                 * Returns the cookies explicitly specified when this handshake was accepted (which may be different from the finalized map of headers sent to the client).
284                 *
285                 * @return the cookies explicitly specified when this handshake was accepted
286                 */
287                @Nullable
288                public Set<@NonNull ResponseCookie> getCookies() {
289                        return this.cookies;
290                }
291
292                /**
293                 * Returns the client context, if specified, for this accepted Server-Sent Event handshake.
294                 * <p>
295                 * Useful for "targeted" broadcasts via {@link ServerSentEventBroadcaster#broadcastEvent(Function, Function)}.
296                 *
297                 * @return the client context, or {@link Optional#empty()} if none was specified
298                 */
299                @NonNull
300                public Optional<Object> getClientContext() {
301                        return Optional.ofNullable(this.clientContext);
302                }
303
304                /**
305                 * Returns the client initialization function, if specified, for this accepted Server-Sent Event handshake.
306                 *
307                 * @return the client initialization function, or {@link Optional#empty()} if none was specified
308                 */
309                @NonNull
310                public Optional<Consumer<ServerSentEventUnicaster>> getClientInitializer() {
311                        return Optional.ofNullable(this.clientInitializer);
312                }
313
314                @Override
315                public String toString() {
316                        return format("%s{headers=%s, cookies=%s, clientContext=%s clientInitializer=%s}",
317                                        Accepted.class.getSimpleName(), getHeaders(), getCookies(),
318                                        (getClientContext().isPresent() ? getClientContext().get() : "[not specified]"),
319                                        (getClientInitializer().isPresent() ? "[specified]" : "[not specified]")
320                        );
321                }
322
323                @Override
324                public boolean equals(@Nullable Object object) {
325                        if (this == object)
326                                return true;
327
328                        if (!(object instanceof Accepted accepted))
329                                return false;
330
331                        return Objects.equals(getHeaders(), accepted.getHeaders())
332                                        && Objects.equals(getCookies(), accepted.getCookies())
333                                        && Objects.equals(getClientContext(), accepted.getClientContext())
334                                        && Objects.equals(getClientInitializer(), accepted.getClientInitializer());
335                }
336
337                @Override
338                public int hashCode() {
339                        return Objects.hash(getHeaders(), getCookies(), getClientContext(), getClientInitializer());
340                }
341        }
342
343        /**
344         * Type which indicates a rejected Server-Sent Event handshake.
345         * <p>
346         * Instances can be acquired via the {@link HandshakeResult#rejectWithResponse(Response)} factory method.
347         * <p>
348         * 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>.
349         *
350         * @author <a href="https://www.revetkn.com">Mark Allen</a>
351         */
352        @ThreadSafe
353        final class Rejected implements HandshakeResult {
354                @NonNull
355                private final Response response;
356
357                private Rejected(@NonNull Response response) {
358                        requireNonNull(response);
359                        this.response = response;
360                }
361
362                /**
363                 * The logical response to send to the client for this handshake rejection.
364                 *
365                 * @return the logical response for this handshake rejection
366                 */
367                @NonNull
368                public Response getResponse() {
369                        return this.response;
370                }
371
372                @Override
373                public String toString() {
374                        return format("%s{response=%s}", Rejected.class.getSimpleName(), getResponse());
375                }
376
377                @Override
378                public boolean equals(@Nullable Object object) {
379                        if (this == object)
380                                return true;
381
382                        if (!(object instanceof Rejected rejected))
383                                return false;
384
385                        return Objects.equals(getResponse(), rejected.getResponse());
386                }
387
388                @Override
389                public int hashCode() {
390                        return Objects.hash(getResponse());
391                }
392        }
393}