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}