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}