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