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