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.ServerSentEventRequestResult.HandshakeAccepted; 020import com.soklet.ServerSentEventRequestResult.HandshakeRejected; 021import com.soklet.internal.spring.LinkedCaseInsensitiveMap; 022 023import javax.annotation.Nonnull; 024import javax.annotation.Nullable; 025import javax.annotation.concurrent.ThreadSafe; 026import java.io.BufferedReader; 027import java.io.IOException; 028import java.io.InputStreamReader; 029import java.lang.reflect.InvocationTargetException; 030import java.nio.charset.Charset; 031import java.nio.charset.StandardCharsets; 032import java.time.Duration; 033import java.time.Instant; 034import java.time.ZoneId; 035import java.time.format.DateTimeFormatter; 036import java.util.ArrayList; 037import java.util.Collections; 038import java.util.EnumSet; 039import java.util.LinkedHashMap; 040import java.util.List; 041import java.util.Locale; 042import java.util.Map; 043import java.util.Map.Entry; 044import java.util.Objects; 045import java.util.Optional; 046import java.util.Set; 047import java.util.concurrent.ConcurrentHashMap; 048import java.util.concurrent.CopyOnWriteArraySet; 049import java.util.concurrent.CountDownLatch; 050import java.util.concurrent.atomic.AtomicBoolean; 051import java.util.concurrent.atomic.AtomicReference; 052import java.util.concurrent.locks.ReentrantLock; 053import java.util.function.Consumer; 054import java.util.stream.Collectors; 055 056import static com.soklet.Utilities.emptyByteArray; 057import static java.lang.String.format; 058import static java.util.Objects.requireNonNull; 059 060/** 061 * Soklet's main class - manages a {@link Server} (and optionally a {@link ServerSentEventServer}) using the provided system configuration. 062 * <p> 063 * <pre>{@code // Use out-of-the-box defaults 064 * SokletConfig config = SokletConfig.withServer( 065 * Server.withPort(8080).build() 066 * ).build(); 067 * 068 * try (Soklet soklet = Soklet.withConfig(config)) { 069 * soklet.start(); 070 * System.out.println("Soklet started, press [enter] to exit"); 071 * soklet.awaitShutdown(ShutdownTrigger.ENTER_KEY); 072 * }}</pre> 073 * <p> 074 * Soklet also offers an off-network {@link Simulator} concept via {@link #runSimulator(SokletConfig, Consumer)}, useful for integration testing. 075 * <p> 076 * Given a <em>Resource Method</em>... 077 * <pre>{@code public class HelloResource { 078 * @GET("/hello") 079 * public String hello(@QueryParameter String name) { 080 * return String.format("Hello, %s", name); 081 * } 082 * }}</pre> 083 * ...we might test it like this: 084 * <pre>{@code @Test 085 * public void integrationTest() { 086 * // Just use your app's existing configuration 087 * SokletConfig config = obtainMySokletConfig(); 088 * 089 * // Instead of running on a real HTTP server that listens on a port, 090 * // a non-network Simulator is provided against which you can 091 * // issue requests and receive responses. 092 * Soklet.runSimulator(config, (simulator -> { 093 * // Construct a request 094 * Request request = Request.withPath(HttpMethod.GET, "/hello") 095 * .queryParameters(Map.of("name", Set.of("Mark"))) 096 * .build(); 097 * 098 * // Perform the request and get a handle to the response 099 * RequestResult result = simulator.performRequest(request); 100 * MarshaledResponse marshaledResponse = result.getMarshaledResponse(); 101 * 102 * // Verify status code 103 * Integer expectedCode = 200; 104 * Integer actualCode = marshaledResponse.getStatusCode(); 105 * assertEquals(expectedCode, actualCode, "Bad status code"); 106 * 107 * // Verify response body 108 * marshaledResponse.getBody().ifPresentOrElse(body -> { 109 * String expectedBody = "Hello, Mark"; 110 * String actualBody = new String(body, StandardCharsets.UTF_8); 111 * assertEquals(expectedBody, actualBody, "Bad response body"); 112 * }, () -> { 113 * Assertions.fail("No response body"); 114 * }); 115 * })); 116 * }}</pre> 117 * <p> 118 * The {@link Simulator} also supports Server-Sent Events. 119 * <p> 120 * Integration testing documentation is available at <a href="https://www.soklet.com/docs/testing">https://www.soklet.com/docs/testing</a>. 121 * 122 * @author <a href="https://www.revetkn.com">Mark Allen</a> 123 */ 124@ThreadSafe 125public final class Soklet implements AutoCloseable { 126 @Nonnull 127 private static final Map<String, Set<String>> DEFAULT_ACCEPTED_HANDSHAKE_HEADERS; 128 129 static { 130 // Generally speaking, we always want these headers for SSE streaming responses. 131 // Users can override if they think necessary 132 LinkedCaseInsensitiveMap<Set<String>> defaultAcceptedHandshakeHeaders = new LinkedCaseInsensitiveMap<>(4); 133 defaultAcceptedHandshakeHeaders.put("Content-Type", Set.of("text/event-stream; charset=UTF-8")); 134 defaultAcceptedHandshakeHeaders.put("Cache-Control", Set.of("no-cache", "no-transform")); 135 defaultAcceptedHandshakeHeaders.put("Connection", Set.of("keep-alive")); 136 defaultAcceptedHandshakeHeaders.put("X-Accel-Buffering", Set.of("no")); 137 138 DEFAULT_ACCEPTED_HANDSHAKE_HEADERS = Collections.unmodifiableMap(defaultAcceptedHandshakeHeaders); 139 } 140 141 /** 142 * Acquires a Soklet instance with the given configuration. 143 * 144 * @param sokletConfig configuration that drives the Soklet system 145 * @return a Soklet instance 146 */ 147 @Nonnull 148 public static Soklet withConfig(@Nonnull SokletConfig sokletConfig) { 149 requireNonNull(sokletConfig); 150 return new Soklet(sokletConfig); 151 } 152 153 @Nonnull 154 private final SokletConfig sokletConfig; 155 @Nonnull 156 private final ReentrantLock lock; 157 @Nonnull 158 private final AtomicReference<CountDownLatch> awaitShutdownLatchReference; 159 160 /** 161 * Creates a Soklet instance with the given configuration. 162 * 163 * @param sokletConfig configuration that drives the Soklet system 164 */ 165 private Soklet(@Nonnull SokletConfig sokletConfig) { 166 requireNonNull(sokletConfig); 167 168 this.sokletConfig = sokletConfig; 169 this.lock = new ReentrantLock(); 170 this.awaitShutdownLatchReference = new AtomicReference<>(new CountDownLatch(1)); 171 172 // Fail fast in the event that Soklet appears misconfigured 173 if (sokletConfig.getResourceMethodResolver().getResourceMethods().size() == 0) 174 throw new IllegalStateException(format("No Soklet Resource Methods were found. Please ensure your %s is configured correctly. " 175 + "See https://www.soklet.com/docs/request-handling#resource-method-resolution for details.", ResourceMethodResolver.class.getSimpleName())); 176 177 // Use a layer of indirection here so the Soklet type does not need to directly implement the `RequestHandler` interface. 178 // Reasoning: the `handleRequest` method for Soklet should not be public, which might lead to accidental invocation by users. 179 // That method should only be called by the managed `Server` instance. 180 Soklet soklet = this; 181 182 sokletConfig.getServer().initialize(getSokletConfig(), (request, marshaledResponseConsumer) -> { 183 // Delegate to Soklet's internal request handling method 184 soklet.handleRequest(request, ServerType.STANDARD_HTTP, marshaledResponseConsumer); 185 }); 186 187 ServerSentEventServer serverSentEventServer = sokletConfig.getServerSentEventServer().orElse(null); 188 189 if (serverSentEventServer != null) 190 serverSentEventServer.initialize(sokletConfig, (request, marshaledResponseConsumer) -> { 191 // Delegate to Soklet's internal request handling method 192 soklet.handleRequest(request, ServerType.SERVER_SENT_EVENT, marshaledResponseConsumer); 193 }); 194 } 195 196 /** 197 * Starts the managed server instance[s]. 198 * <p> 199 * If the managed server[s] are already started, this is a no-op. 200 */ 201 public void start() { 202 getLock().lock(); 203 204 try { 205 if (isStarted()) 206 return; 207 208 getAwaitShutdownLatchReference().set(new CountDownLatch(1)); 209 210 SokletConfig sokletConfig = getSokletConfig(); 211 LifecycleInterceptor lifecycleInterceptor = sokletConfig.getLifecycleInterceptor(); 212 Server server = sokletConfig.getServer(); 213 214 lifecycleInterceptor.willStartServer(server); 215 server.start(); 216 lifecycleInterceptor.didStartServer(server); 217 218 ServerSentEventServer serverSentEventServer = sokletConfig.getServerSentEventServer().orElse(null); 219 220 if (serverSentEventServer != null) { 221 lifecycleInterceptor.willStartServerSentEventServer(serverSentEventServer); 222 serverSentEventServer.start(); 223 lifecycleInterceptor.didStartServerSentEventServer(serverSentEventServer); 224 } 225 } finally { 226 getLock().unlock(); 227 } 228 } 229 230 /** 231 * Stops the managed server instance[s]. 232 * <p> 233 * If the managed server[s] are already stopped, this is a no-op. 234 */ 235 public void stop() { 236 getLock().lock(); 237 238 try { 239 if (isStarted()) { 240 SokletConfig sokletConfig = getSokletConfig(); 241 LifecycleInterceptor lifecycleInterceptor = sokletConfig.getLifecycleInterceptor(); 242 Server server = sokletConfig.getServer(); 243 244 if (server.isStarted()) { 245 lifecycleInterceptor.willStopServer(server); 246 server.stop(); 247 lifecycleInterceptor.didStopServer(server); 248 } 249 250 ServerSentEventServer serverSentEventServer = sokletConfig.getServerSentEventServer().orElse(null); 251 252 if (serverSentEventServer != null && serverSentEventServer.isStarted()) { 253 lifecycleInterceptor.willStopServerSentEventServer(serverSentEventServer); 254 serverSentEventServer.stop(); 255 lifecycleInterceptor.didStopServerSentEventServer(serverSentEventServer); 256 } 257 } 258 } finally { 259 try { 260 getAwaitShutdownLatchReference().get().countDown(); 261 } finally { 262 getLock().unlock(); 263 } 264 } 265 } 266 267 /** 268 * Blocks the current thread until JVM shutdown ({@code SIGTERM/SIGINT/System.exit(...)} and so forth), <strong>or</strong> if one of the provided {@code shutdownTriggers} occurs. 269 * <p> 270 * This method will automatically invoke this instance's {@link #stop()} method once it becomes unblocked. 271 * <p> 272 * <strong>Notes regarding {@link ShutdownTrigger#ENTER_KEY}:</strong> 273 * <ul> 274 * <li>It will invoke {@link #stop()} on <i>all</i> Soklet instances, as stdin is process-wide</li> 275 * <li>It is only supported for environments with an interactive TTY and will be ignored if none exists (e.g. running in a Docker container) - Soklet will detect this and fire {@link LifecycleInterceptor#didReceiveLogEvent(LogEvent)} with an event of type {@link LogEventType#CONFIGURATION_UNSUPPORTED}</li> 276 * </ul> 277 * 278 * @param shutdownTriggers additional trigger[s] which signal that shutdown should occur, e.g. {@link ShutdownTrigger#ENTER_KEY} for "enter key pressed" 279 * @throws InterruptedException if the current thread has its interrupted status set on entry to this method, or is interrupted while waiting 280 */ 281 public void awaitShutdown(@Nullable ShutdownTrigger... shutdownTriggers) throws InterruptedException { 282 Thread shutdownHook = null; 283 boolean registeredEnterKeyShutdownTrigger = false; 284 Set<ShutdownTrigger> shutdownTriggersAsSet = shutdownTriggers == null || shutdownTriggers.length == 0 ? Set.of() : EnumSet.copyOf(Set.of(shutdownTriggers)); 285 286 try { 287 // Optionally listen for enter key 288 if (shutdownTriggersAsSet.contains(ShutdownTrigger.ENTER_KEY)) { 289 registeredEnterKeyShutdownTrigger = KeypressManager.register(this); // returns false if stdin unusable/disabled 290 291 if (!registeredEnterKeyShutdownTrigger) { 292 LogEvent logEvent = LogEvent.with( 293 LogEventType.CONFIGURATION_UNSUPPORTED, 294 format("Ignoring request for %s.%s - it is unsupported in this environment (no interactive TTY detected)", ShutdownTrigger.class.getSimpleName(), ShutdownTrigger.ENTER_KEY.name()) 295 ).build(); 296 297 getSokletConfig().getLifecycleInterceptor().didReceiveLogEvent(logEvent); 298 } 299 } 300 301 // Always register a shutdown hook 302 shutdownHook = new Thread(() -> { 303 try { 304 stop(); 305 } catch (Throwable ignored) { 306 // Nothing to do 307 } 308 }, "soklet-shutdown-hook"); 309 310 Runtime.getRuntime().addShutdownHook(shutdownHook); 311 312 // Wait until "close" finishes 313 getAwaitShutdownLatchReference().get().await(); 314 } finally { 315 if (registeredEnterKeyShutdownTrigger) 316 KeypressManager.unregister(this); 317 318 try { 319 Runtime.getRuntime().removeShutdownHook(shutdownHook); 320 } catch (IllegalStateException ignored) { 321 // JVM shutting down 322 } 323 } 324 } 325 326 /** 327 * Handles "awaitShutdown" for {@link ShutdownTrigger#ENTER_KEY} by listening to stdin - all Soklet instances are terminated on keypress. 328 */ 329 @ThreadSafe 330 private static final class KeypressManager { 331 @Nonnull 332 private static final Set<Soklet> SOKLET_REGISTRY; 333 @Nonnull 334 private static final AtomicBoolean LISTENER_STARTED; 335 336 static { 337 SOKLET_REGISTRY = new CopyOnWriteArraySet<>(); 338 LISTENER_STARTED = new AtomicBoolean(false); 339 } 340 341 /** 342 * Register a Soklet for Enter-to-stop support. Returns true iff a listener is (or was already) active. 343 * If System.in is not usable (or disabled), returns false and does nothing. 344 */ 345 @Nonnull 346 synchronized static Boolean register(@Nonnull Soklet soklet) { 347 requireNonNull(soklet); 348 349 // If stdin is not readable (e.g., container with no TTY), don't start a listener. 350 if (!canReadFromStdin()) 351 return false; 352 353 SOKLET_REGISTRY.add(soklet); 354 355 // Start a single process-wide listener once. 356 if (LISTENER_STARTED.compareAndSet(false, true)) { 357 Thread thread = new Thread(KeypressManager::runLoop, "soklet-keypress-shutdown-listener"); 358 thread.setDaemon(true); // never block JVM exit 359 thread.start(); 360 } 361 362 return true; 363 } 364 365 synchronized static void unregister(@Nonnull Soklet soklet) { 366 SOKLET_REGISTRY.remove(soklet); 367 // We intentionally keep the listener alive; it's daemon and cheap. 368 // If stdin hits EOF, the listener exits on its own. 369 } 370 371 /** 372 * Heuristic: if System.in is present and calling available() doesn't throw, 373 * treat it as readable. Works even in IDEs where System.console() is null. 374 */ 375 @Nonnull 376 private static Boolean canReadFromStdin() { 377 if (System.in == null) 378 return false; 379 380 try { 381 // available() >= 0 means stream is open; 0 means no buffered data (that’s fine). 382 return System.in.available() >= 0; 383 } catch (IOException e) { 384 return false; 385 } 386 } 387 388 /** 389 * Single blocking read on stdin. On any line (or EOF), stop all registered servers. 390 */ 391 private static void runLoop() { 392 try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { 393 // Blocks until newline or EOF; EOF (null) happens with /dev/null or closed pipe. 394 bufferedReader.readLine(); 395 stopAllSoklets(); 396 } catch (Throwable ignored) { 397 // If stdin is closed mid-run, just exit quietly. 398 } 399 } 400 401 synchronized private static void stopAllSoklets() { 402 // Either a line or EOF → stop everything that’s currently registered. 403 for (Soklet soklet : SOKLET_REGISTRY) { 404 try { 405 soklet.stop(); 406 } catch (Throwable ignored) { 407 // Nothing to do 408 } 409 } 410 } 411 412 private KeypressManager() {} 413 } 414 415 /** 416 * Nonpublic "informal" implementation of {@link com.soklet.Server.RequestHandler} so Soklet does not need to expose {@code handleRequest} publicly. 417 * Reasoning: users of this library should never call {@code handleRequest} directly - it should only be invoked in response to events 418 * provided by a {@link Server} or {@link ServerSentEventServer} implementation. 419 */ 420 protected void handleRequest(@Nonnull Request request, 421 @Nonnull ServerType serverType, 422 @Nonnull Consumer<RequestResult> requestResultConsumer) { 423 requireNonNull(request); 424 requireNonNull(serverType); 425 requireNonNull(requestResultConsumer); 426 427 Instant processingStarted = Instant.now(); 428 429 SokletConfig sokletConfig = getSokletConfig(); 430 ResourceMethodResolver resourceMethodResolver = sokletConfig.getResourceMethodResolver(); 431 ResponseMarshaler responseMarshaler = sokletConfig.getResponseMarshaler(); 432 LifecycleInterceptor lifecycleInterceptor = sokletConfig.getLifecycleInterceptor(); 433 434 // Holders to permit mutable effectively-final variables 435 AtomicReference<MarshaledResponse> marshaledResponseHolder = new AtomicReference<>(); 436 AtomicReference<Throwable> resourceMethodResolutionExceptionHolder = new AtomicReference<>(); 437 AtomicReference<Request> requestHolder = new AtomicReference<>(request); 438 AtomicReference<ResourceMethod> resourceMethodHolder = new AtomicReference<>(); 439 AtomicReference<RequestResult> requestResultHolder = new AtomicReference<>(); 440 441 // Holders to permit mutable effectively-final state tracking 442 AtomicBoolean willStartResponseWritingCompleted = new AtomicBoolean(false); 443 AtomicBoolean didFinishResponseWritingCompleted = new AtomicBoolean(false); 444 AtomicBoolean didFinishRequestHandlingCompleted = new AtomicBoolean(false); 445 446 List<Throwable> throwables = new ArrayList<>(10); 447 448 Consumer<LogEvent> safelyLog = (logEvent -> { 449 try { 450 lifecycleInterceptor.didReceiveLogEvent(logEvent); 451 } catch (Throwable throwable) { 452 throwable.printStackTrace(); 453 throwables.add(throwable); 454 } 455 }); 456 457 requestHolder.set(request); 458 459 try { 460 // Do we have an exact match for this resource method? 461 resourceMethodHolder.set(resourceMethodResolver.resourceMethodForRequest(requestHolder.get(), serverType).orElse(null)); 462 } catch (Throwable t) { 463 safelyLog.accept(LogEvent.with(LogEventType.RESOURCE_METHOD_RESOLUTION_FAILED, "Unable to resolve Resource Method") 464 .throwable(t) 465 .request(requestHolder.get()) 466 .build()); 467 468 // If an exception occurs here, keep track of it - we will surface them after letting LifecycleInterceptor 469 // see that a request has come in. 470 throwables.add(t); 471 resourceMethodResolutionExceptionHolder.set(t); 472 } 473 474 try { 475 lifecycleInterceptor.wrapRequest(request, resourceMethodHolder.get(), (wrappedRequest) -> { 476 requestHolder.set(wrappedRequest); 477 478 try { 479 lifecycleInterceptor.didStartRequestHandling(requestHolder.get(), resourceMethodHolder.get()); 480 } catch (Throwable t) { 481 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_DID_START_REQUEST_HANDLING_FAILED, 482 format("An exception occurred while invoking %s::didStartRequestHandling", 483 LifecycleInterceptor.class.getSimpleName())) 484 .throwable(t) 485 .request(requestHolder.get()) 486 .resourceMethod(resourceMethodHolder.get()) 487 .build()); 488 489 throwables.add(t); 490 } 491 492 try { 493 lifecycleInterceptor.interceptRequest(request, resourceMethodHolder.get(), (interceptorRequest) -> { 494 requestHolder.set(interceptorRequest); 495 496 try { 497 if (resourceMethodResolutionExceptionHolder.get() != null) 498 throw resourceMethodResolutionExceptionHolder.get(); 499 500 RequestResult requestResult = toRequestResult(requestHolder.get(), resourceMethodHolder.get(), serverType); 501 requestResultHolder.set(requestResult); 502 503 MarshaledResponse originalMarshaledResponse = requestResult.getMarshaledResponse(); 504 MarshaledResponse updatedMarshaledResponse = requestResult.getMarshaledResponse(); 505 506 // A few special cases that are "global" in that they can affect all requests and 507 // need to happen after marshaling the response... 508 509 // 1. Customize response for HEAD (e.g. remove body, set Content-Length header) 510 updatedMarshaledResponse = applyHeadResponseIfApplicable(request, updatedMarshaledResponse); 511 512 // 2. Apply other standard response customizations (CORS, Content-Length) 513 // Note that we don't want to write Content-Length for SSE "accepted" handshakes 514 HandshakeResult handshakeResult = requestResult.getHandshakeResult().orElse(null); 515 boolean suppressContentLength = handshakeResult != null && handshakeResult instanceof HandshakeResult.Accepted; 516 517 updatedMarshaledResponse = applyCommonPropertiesToMarshaledResponse(request, updatedMarshaledResponse, suppressContentLength); 518 519 // Update our result holder with the modified response if necessary 520 if (originalMarshaledResponse != updatedMarshaledResponse) { 521 marshaledResponseHolder.set(updatedMarshaledResponse); 522 requestResultHolder.set(requestResult.copy() 523 .marshaledResponse(updatedMarshaledResponse) 524 .finish()); 525 } 526 527 return updatedMarshaledResponse; 528 } catch (Throwable t) { 529 if (!Objects.equals(t, resourceMethodResolutionExceptionHolder.get())) { 530 throwables.add(t); 531 532 safelyLog.accept(LogEvent.with(LogEventType.REQUEST_PROCESSING_FAILED, 533 "An exception occurred while processing request") 534 .throwable(t) 535 .request(requestHolder.get()) 536 .resourceMethod(resourceMethodHolder.get()) 537 .build()); 538 } 539 540 // Unhappy path. Try to use configuration's exception response marshaler... 541 try { 542 MarshaledResponse marshaledResponse = responseMarshaler.forThrowable(requestHolder.get(), t, resourceMethodHolder.get()); 543 marshaledResponse = applyCommonPropertiesToMarshaledResponse(request, marshaledResponse); 544 marshaledResponseHolder.set(marshaledResponse); 545 546 return marshaledResponse; 547 } catch (Throwable t2) { 548 throwables.add(t2); 549 550 safelyLog.accept(LogEvent.with(LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED, 551 format("An exception occurred while trying to write an exception response for %s", t)) 552 .throwable(t2) 553 .request(requestHolder.get()) 554 .resourceMethod(resourceMethodHolder.get()) 555 .build()); 556 557 // The configuration's exception response marshaler failed - provide a failsafe response to recover 558 return provideFailsafeMarshaledResponse(requestHolder.get(), t2); 559 } 560 } 561 }, (interceptorMarshaledResponse) -> { 562 marshaledResponseHolder.set(interceptorMarshaledResponse); 563 }); 564 } catch (Throwable t) { 565 throwables.add(t); 566 567 try { 568 // In the event that an error occurs during processing of a LifecycleInterceptor method, for example 569 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_INTERCEPT_REQUEST_FAILED, 570 format("An exception occurred while invoking %s::interceptRequest", LifecycleInterceptor.class.getSimpleName())) 571 .throwable(t) 572 .request(requestHolder.get()) 573 .resourceMethod(resourceMethodHolder.get()) 574 .build()); 575 576 MarshaledResponse marshaledResponse = responseMarshaler.forThrowable(requestHolder.get(), t, resourceMethodHolder.get()); 577 marshaledResponse = applyCommonPropertiesToMarshaledResponse(request, marshaledResponse); 578 marshaledResponseHolder.set(marshaledResponse); 579 } catch (Throwable t2) { 580 throwables.add(t2); 581 582 safelyLog.accept(LogEvent.with(LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED, 583 format("An exception occurred while invoking %s::forThrowable when trying to write an exception response for %s", ResponseMarshaler.class.getSimpleName(), t)) 584 .throwable(t2) 585 .request(requestHolder.get()) 586 .resourceMethod(resourceMethodHolder.get()) 587 .build()); 588 589 marshaledResponseHolder.set(provideFailsafeMarshaledResponse(requestHolder.get(), t2)); 590 } 591 } finally { 592 try { 593 try { 594 lifecycleInterceptor.willStartResponseWriting(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get()); 595 } finally { 596 willStartResponseWritingCompleted.set(true); 597 } 598 599 Instant responseWriteStarted = Instant.now(); 600 601 try { 602 RequestResult requestResult = requestResultHolder.get(); 603 604 if (requestResult != null) 605 requestResultConsumer.accept(requestResult); 606 else 607 requestResultConsumer.accept(RequestResult.withMarshaledResponse(marshaledResponseHolder.get()) 608 .resourceMethod(resourceMethodHolder.get()) 609 .build()); 610 611 Instant responseWriteFinished = Instant.now(); 612 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 613 614 try { 615 lifecycleInterceptor.didFinishResponseWriting(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration, null); 616 } catch (Throwable t) { 617 throwables.add(t); 618 619 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_DID_FINISH_RESPONSE_WRITING_FAILED, 620 format("An exception occurred while invoking %s::didFinishResponseWriting", 621 LifecycleInterceptor.class.getSimpleName())) 622 .throwable(t) 623 .request(requestHolder.get()) 624 .resourceMethod(resourceMethodHolder.get()) 625 .marshaledResponse(marshaledResponseHolder.get()) 626 .build()); 627 } finally { 628 didFinishResponseWritingCompleted.set(true); 629 } 630 } catch (Throwable t) { 631 throwables.add(t); 632 633 Instant responseWriteFinished = Instant.now(); 634 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 635 636 try { 637 lifecycleInterceptor.didFinishResponseWriting(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration, t); 638 } catch (Throwable t2) { 639 throwables.add(t2); 640 641 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_DID_FINISH_RESPONSE_WRITING_FAILED, 642 format("An exception occurred while invoking %s::didFinishResponseWriting", 643 LifecycleInterceptor.class.getSimpleName())) 644 .throwable(t2) 645 .request(requestHolder.get()) 646 .resourceMethod(resourceMethodHolder.get()) 647 .marshaledResponse(marshaledResponseHolder.get()) 648 .build()); 649 } 650 } 651 } finally { 652 try { 653 Instant processingFinished = Instant.now(); 654 Duration processingDuration = Duration.between(processingStarted, processingFinished); 655 656 lifecycleInterceptor.didFinishRequestHandling(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), processingDuration, Collections.unmodifiableList(throwables)); 657 } catch (Throwable t) { 658 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_DID_FINISH_REQUEST_HANDLING_FAILED, 659 format("An exception occurred while invoking %s::didFinishRequestHandling", 660 LifecycleInterceptor.class.getSimpleName())) 661 .throwable(t) 662 .request(requestHolder.get()) 663 .resourceMethod(resourceMethodHolder.get()) 664 .marshaledResponse(marshaledResponseHolder.get()) 665 .build()); 666 } finally { 667 didFinishRequestHandlingCompleted.set(true); 668 } 669 } 670 } 671 }); 672 } catch (Throwable t) { 673 // If an error occurred during request wrapping, it's possible a response was never written/communicated back to LifecycleInterceptor. 674 // Detect that here and inform LifecycleInterceptor accordingly. 675 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_WRAP_REQUEST_FAILED, 676 format("An exception occurred while invoking %s::wrapRequest", 677 LifecycleInterceptor.class.getSimpleName())) 678 .throwable(t) 679 .request(requestHolder.get()) 680 .resourceMethod(resourceMethodHolder.get()) 681 .marshaledResponse(marshaledResponseHolder.get()) 682 .build()); 683 684 // If we don't have a response, let the marshaler try to make one for the exception. 685 // If that fails, use the failsafe. 686 if (marshaledResponseHolder.get() == null) { 687 try { 688 MarshaledResponse marshaledResponse = responseMarshaler.forThrowable(requestHolder.get(), t, resourceMethodHolder.get()); 689 marshaledResponse = applyCommonPropertiesToMarshaledResponse(request, marshaledResponse); 690 marshaledResponseHolder.set(marshaledResponse); 691 } catch (Throwable t2) { 692 throwables.add(t2); 693 694 safelyLog.accept(LogEvent.with(LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED, 695 format("An exception occurred during request wrapping while invoking %s::forThrowable", 696 ResponseMarshaler.class.getSimpleName())) 697 .throwable(t2) 698 .request(requestHolder.get()) 699 .resourceMethod(resourceMethodHolder.get()) 700 .marshaledResponse(marshaledResponseHolder.get()) 701 .build()); 702 703 marshaledResponseHolder.set(provideFailsafeMarshaledResponse(requestHolder.get(), t)); 704 } 705 } 706 707 if (!willStartResponseWritingCompleted.get()) { 708 try { 709 lifecycleInterceptor.willStartResponseWriting(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get()); 710 } catch (Throwable t2) { 711 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_WILL_START_RESPONSE_WRITING_FAILED, 712 format("An exception occurred while invoking %s::willStartResponseWriting", 713 LifecycleInterceptor.class.getSimpleName())) 714 .throwable(t2) 715 .request(requestHolder.get()) 716 .resourceMethod(resourceMethodHolder.get()) 717 .marshaledResponse(marshaledResponseHolder.get()) 718 .build()); 719 } 720 } 721 722 try { 723 Instant responseWriteStarted = Instant.now(); 724 725 if (!didFinishResponseWritingCompleted.get()) { 726 try { 727 RequestResult requestResult = requestResultHolder.get(); 728 729 if (requestResult != null) 730 requestResultConsumer.accept(requestResult); 731 else 732 requestResultConsumer.accept(RequestResult.withMarshaledResponse(marshaledResponseHolder.get()) 733 .resourceMethod(resourceMethodHolder.get()) 734 .build()); 735 736 Instant responseWriteFinished = Instant.now(); 737 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 738 739 try { 740 lifecycleInterceptor.didFinishResponseWriting(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration, null); 741 } catch (Throwable t2) { 742 throwables.add(t2); 743 744 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_DID_FINISH_RESPONSE_WRITING_FAILED, 745 format("An exception occurred while invoking %s::didFinishResponseWriting", 746 LifecycleInterceptor.class.getSimpleName())) 747 .throwable(t2) 748 .request(requestHolder.get()) 749 .resourceMethod(resourceMethodHolder.get()) 750 .marshaledResponse(marshaledResponseHolder.get()) 751 .build()); 752 } 753 } catch (Throwable t2) { 754 throwables.add(t2); 755 756 Instant responseWriteFinished = Instant.now(); 757 Duration responseWriteDuration = Duration.between(responseWriteStarted, responseWriteFinished); 758 759 try { 760 lifecycleInterceptor.didFinishResponseWriting(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), responseWriteDuration, t); 761 } catch (Throwable t3) { 762 throwables.add(t3); 763 764 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_DID_FINISH_RESPONSE_WRITING_FAILED, 765 format("An exception occurred while invoking %s::didFinishResponseWriting", 766 LifecycleInterceptor.class.getSimpleName())) 767 .throwable(t3) 768 .request(requestHolder.get()) 769 .resourceMethod(resourceMethodHolder.get()) 770 .marshaledResponse(marshaledResponseHolder.get()) 771 .build()); 772 } 773 } 774 } 775 } finally { 776 if (!didFinishRequestHandlingCompleted.get()) { 777 try { 778 Instant processingFinished = Instant.now(); 779 Duration processingDuration = Duration.between(processingStarted, processingFinished); 780 781 lifecycleInterceptor.didFinishRequestHandling(requestHolder.get(), resourceMethodHolder.get(), marshaledResponseHolder.get(), processingDuration, Collections.unmodifiableList(throwables)); 782 } catch (Throwable t2) { 783 safelyLog.accept(LogEvent.with(LogEventType.LIFECYCLE_INTERCEPTOR_DID_FINISH_REQUEST_HANDLING_FAILED, 784 format("An exception occurred while invoking %s::didFinishRequestHandling", 785 LifecycleInterceptor.class.getSimpleName())) 786 .throwable(t2) 787 .request(requestHolder.get()) 788 .resourceMethod(resourceMethodHolder.get()) 789 .marshaledResponse(marshaledResponseHolder.get()) 790 .build()); 791 } 792 } 793 } 794 } 795 } 796 797 @Nonnull 798 protected RequestResult toRequestResult(@Nonnull Request request, 799 @Nullable ResourceMethod resourceMethod, 800 @Nonnull ServerType serverType) throws Throwable { 801 requireNonNull(request); 802 requireNonNull(serverType); 803 804 ResourceMethodParameterProvider resourceMethodParameterProvider = getSokletConfig().getResourceMethodParameterProvider(); 805 InstanceProvider instanceProvider = getSokletConfig().getInstanceProvider(); 806 CorsAuthorizer corsAuthorizer = getSokletConfig().getCorsAuthorizer(); 807 ResourceMethodResolver resourceMethodResolver = getSokletConfig().getResourceMethodResolver(); 808 ResponseMarshaler responseMarshaler = getSokletConfig().getResponseMarshaler(); 809 CorsPreflight corsPreflight = request.getCorsPreflight().orElse(null); 810 811 // Special short-circuit for big requests 812 if (request.isContentTooLarge()) 813 return RequestResult.withMarshaledResponse(responseMarshaler.forContentTooLarge(request, resourceMethodResolver.resourceMethodForRequest(request, serverType).orElse(null))) 814 .resourceMethod(resourceMethod) 815 .build(); 816 817 // Special short-circuit for OPTIONS * 818 if (request.getResourcePath() == ResourcePath.OPTIONS_SPLAT_RESOURCE_PATH) 819 return RequestResult.withMarshaledResponse(responseMarshaler.forOptionsSplat(request)).build(); 820 821 // No resource method was found for this HTTP method and path. 822 if (resourceMethod == null) { 823 // If this was an OPTIONS request, do special processing. 824 // If not, figure out if we should return a 404 or 405. 825 if (request.getHttpMethod() == HttpMethod.OPTIONS) { 826 // See what methods are available to us for this request's path 827 Map<HttpMethod, ResourceMethod> matchingResourceMethodsByHttpMethod = resolveMatchingResourceMethodsByHttpMethod(request, resourceMethodResolver, serverType); 828 829 // Special handling for CORS preflight requests, if needed 830 if (corsPreflight != null) { 831 // Let configuration function determine if we should authorize this request. 832 // Discard any OPTIONS references - see https://stackoverflow.com/a/68529748 833 Map<HttpMethod, ResourceMethod> nonOptionsMatchingResourceMethodsByHttpMethod = matchingResourceMethodsByHttpMethod.entrySet().stream() 834 .filter(entry -> entry.getKey() != HttpMethod.OPTIONS) 835 .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); 836 837 CorsPreflightResponse corsPreflightResponse = corsAuthorizer.authorizePreflight(request, corsPreflight, nonOptionsMatchingResourceMethodsByHttpMethod).orElse(null); 838 839 // Allow or reject CORS depending on what the function said to do 840 if (corsPreflightResponse != null) { 841 // Allow 842 MarshaledResponse marshaledResponse = responseMarshaler.forCorsPreflightAllowed(request, corsPreflight, corsPreflightResponse); 843 844 return RequestResult.withMarshaledResponse(marshaledResponse) 845 .corsPreflightResponse(corsPreflightResponse) 846 .resourceMethod(resourceMethod) 847 .build(); 848 } 849 850 // Reject 851 return RequestResult.withMarshaledResponse(responseMarshaler.forCorsPreflightRejected(request, corsPreflight)) 852 .resourceMethod(resourceMethod) 853 .build(); 854 } else { 855 // Just a normal OPTIONS response (non-CORS-preflight). 856 // If there's a matching OPTIONS resource method for this OPTIONS request, then invoke it. 857 ResourceMethod optionsResourceMethod = matchingResourceMethodsByHttpMethod.get(HttpMethod.OPTIONS); 858 859 if (optionsResourceMethod != null) { 860 resourceMethod = optionsResourceMethod; 861 } else { 862 // Ensure OPTIONS is always present in the map, even if there is no explicit matching resource method for it 863 if (!matchingResourceMethodsByHttpMethod.containsKey(HttpMethod.OPTIONS)) 864 matchingResourceMethodsByHttpMethod.put(HttpMethod.OPTIONS, null); 865 866 // Ensure HEAD is always present in the map, even if there is no explicit matching resource method for it 867 if (!matchingResourceMethodsByHttpMethod.containsKey(HttpMethod.HEAD)) 868 matchingResourceMethodsByHttpMethod.put(HttpMethod.HEAD, null); 869 870 return RequestResult.withMarshaledResponse(responseMarshaler.forOptions(request, matchingResourceMethodsByHttpMethod.keySet())) 871 .resourceMethod(resourceMethod) 872 .build(); 873 } 874 } 875 } else if (request.getHttpMethod() == HttpMethod.HEAD) { 876 // If there's a matching GET resource method for this HEAD request, then invoke it 877 Request headGetRequest = request.copy().httpMethod(HttpMethod.GET).finish(); 878 ResourceMethod headGetResourceMethod = resourceMethodResolver.resourceMethodForRequest(headGetRequest, serverType).orElse(null); 879 880 if (headGetResourceMethod != null) 881 resourceMethod = headGetResourceMethod; 882 else 883 return RequestResult.withMarshaledResponse(responseMarshaler.forNotFound(request)) 884 .resourceMethod(resourceMethod) 885 .build(); 886 } else { 887 // Not an OPTIONS request, so it's possible we have a 405. See if other HTTP methods match... 888 Map<HttpMethod, ResourceMethod> otherMatchingResourceMethodsByHttpMethod = resolveMatchingResourceMethodsByHttpMethod(request, resourceMethodResolver, serverType); 889 890 Set<HttpMethod> matchingNonOptionsHttpMethods = otherMatchingResourceMethodsByHttpMethod.keySet().stream() 891 .filter(httpMethod -> httpMethod != HttpMethod.OPTIONS) 892 .collect(Collectors.toSet()); 893 894 // Ensure OPTIONS is always present in the map, even if there is no explicit matching resource method for it 895 if (!otherMatchingResourceMethodsByHttpMethod.containsKey(HttpMethod.OPTIONS)) 896 otherMatchingResourceMethodsByHttpMethod.put(HttpMethod.OPTIONS, null); 897 898 // Ensure HEAD is always present in the map, even if there is no explicit matching resource method for it 899 if (!otherMatchingResourceMethodsByHttpMethod.containsKey(HttpMethod.HEAD)) 900 otherMatchingResourceMethodsByHttpMethod.put(HttpMethod.HEAD, null); 901 902 if (matchingNonOptionsHttpMethods.size() > 0) { 903 // ...if some do, it's a 405 904 return RequestResult.withMarshaledResponse(responseMarshaler.forMethodNotAllowed(request, otherMatchingResourceMethodsByHttpMethod.keySet())) 905 .resourceMethod(resourceMethod) 906 .build(); 907 } else { 908 // no matching resource method found, it's a 404 909 return RequestResult.withMarshaledResponse(responseMarshaler.forNotFound(request)) 910 .resourceMethod(resourceMethod) 911 .build(); 912 } 913 } 914 } 915 916 // Found a resource method - happy path. 917 // 1. Get an instance of the resource class 918 // 2. Get values to pass to the resource method on the resource class 919 // 3. Invoke the resource method and use its return value to drive a response 920 Class<?> resourceClass = resourceMethod.getMethod().getDeclaringClass(); 921 Object resourceClassInstance; 922 923 try { 924 resourceClassInstance = instanceProvider.provide(resourceClass); 925 } catch (Exception e) { 926 throw new IllegalArgumentException(format("Unable to acquire an instance of %s", resourceClass.getName()), e); 927 } 928 929 List<Object> parameterValues = resourceMethodParameterProvider.parameterValuesForResourceMethod(request, resourceMethod); 930 931 Object responseObject; 932 933 try { 934 responseObject = resourceMethod.getMethod().invoke(resourceClassInstance, parameterValues.toArray()); 935 } catch (InvocationTargetException e) { 936 if (e.getTargetException() != null) 937 throw e.getTargetException(); 938 939 throw e; 940 } 941 942 // Unwrap the Optional<T>, if one exists. We do not recurse deeper than one level 943 if (responseObject instanceof Optional<?>) 944 responseObject = ((Optional<?>) responseObject).orElse(null); 945 946 Response response; 947 HandshakeResult handshakeResult = null; 948 949 // If null/void return, it's a 204 950 // If it's a MarshaledResponse object, no marshaling + return it immediately - caller knows exactly what it wants to write. 951 // If it's a Response object, use as is. 952 // If it's a non-Response type of object, assume it's the response body and wrap in a Response. 953 if (responseObject == null) { 954 response = Response.withStatusCode(204).build(); 955 } else if (responseObject instanceof MarshaledResponse) { 956 return RequestResult.withMarshaledResponse((MarshaledResponse) responseObject) 957 .resourceMethod(resourceMethod) 958 .build(); 959 } else if (responseObject instanceof Response) { 960 response = (Response) responseObject; 961 } else if (responseObject instanceof HandshakeResult.Accepted accepted) { // SSE "accepted" handshake 962 return RequestResult.withMarshaledResponse(toMarshaledResponse(accepted)) 963 .resourceMethod(resourceMethod) 964 .handshakeResult(accepted) 965 .build(); 966 } else if (responseObject instanceof HandshakeResult.Rejected rejected) { // SSE "rejected" handshake 967 response = rejected.getResponse(); 968 handshakeResult = rejected; 969 } else { 970 response = Response.withStatusCode(200).body(responseObject).build(); 971 } 972 973 MarshaledResponse marshaledResponse = responseMarshaler.forResourceMethod(request, response, resourceMethod); 974 975 return RequestResult.withMarshaledResponse(marshaledResponse) 976 .response(response) 977 .resourceMethod(resourceMethod) 978 .handshakeResult(handshakeResult) 979 .build(); 980 } 981 982 @Nonnull 983 private MarshaledResponse toMarshaledResponse(@Nonnull HandshakeResult.Accepted accepted) { 984 requireNonNull(accepted); 985 986 Map<String, Set<String>> headers = accepted.getHeaders(); 987 LinkedCaseInsensitiveMap<Set<String>> finalHeaders = new LinkedCaseInsensitiveMap<>(DEFAULT_ACCEPTED_HANDSHAKE_HEADERS.size() + headers.size()); 988 989 // Start with defaults 990 for (Map.Entry<String, Set<String>> e : DEFAULT_ACCEPTED_HANDSHAKE_HEADERS.entrySet()) 991 finalHeaders.put(e.getKey(), e.getValue()); // values already unmodifiable 992 993 // Overlay user-supplied headers (prefer user values on key collision) 994 for (Map.Entry<String, Set<String>> e : headers.entrySet()) { 995 // Defensively copy so callers can't mutate after construction 996 Set<String> values = e.getValue() == null ? Set.of() : Set.copyOf(e.getValue()); 997 finalHeaders.put(e.getKey(), values); 998 } 999 1000 return MarshaledResponse.withStatusCode(200) 1001 .headers(finalHeaders) 1002 .cookies(accepted.getCookies()) 1003 .build(); 1004 } 1005 1006 @Nonnull 1007 protected MarshaledResponse applyHeadResponseIfApplicable(@Nonnull Request request, 1008 @Nonnull MarshaledResponse marshaledResponse) { 1009 if (request.getHttpMethod() != HttpMethod.HEAD) 1010 return marshaledResponse; 1011 1012 return getSokletConfig().getResponseMarshaler().forHead(request, marshaledResponse); 1013 } 1014 1015 // Hat tip to Aslan Parçası and GrayStar 1016 @Nonnull 1017 protected MarshaledResponse applyCommonPropertiesToMarshaledResponse(@Nonnull Request request, 1018 @Nonnull MarshaledResponse marshaledResponse) { 1019 requireNonNull(request); 1020 requireNonNull(marshaledResponse); 1021 1022 return applyCommonPropertiesToMarshaledResponse(request, marshaledResponse, false); 1023 } 1024 1025 @Nonnull 1026 protected MarshaledResponse applyCommonPropertiesToMarshaledResponse(@Nonnull Request request, 1027 @Nonnull MarshaledResponse marshaledResponse, 1028 @Nonnull Boolean suppressContentLength) { 1029 requireNonNull(request); 1030 requireNonNull(marshaledResponse); 1031 requireNonNull(suppressContentLength); 1032 1033 // Don't write Content-Length for an accepted SSE Handshake, for example 1034 if (!suppressContentLength) 1035 marshaledResponse = applyContentLengthIfApplicable(request, marshaledResponse); 1036 1037 // If the Date header is missing, add it using our cached provider 1038 if (!marshaledResponse.getHeaders().containsKey("Date")) 1039 marshaledResponse = marshaledResponse.copy() 1040 .headers(headers -> headers.put("Date", Set.of(CachedHttpDate.getCurrentValue()))) 1041 .finish(); 1042 1043 marshaledResponse = applyCorsResponseIfApplicable(request, marshaledResponse); 1044 1045 return marshaledResponse; 1046 } 1047 1048 @Nonnull 1049 protected MarshaledResponse applyContentLengthIfApplicable(@Nonnull Request request, 1050 @Nonnull MarshaledResponse marshaledResponse) { 1051 requireNonNull(request); 1052 requireNonNull(marshaledResponse); 1053 1054 Set<String> normalizedHeaderNames = marshaledResponse.getHeaders().keySet().stream() 1055 .map(headerName -> headerName.toLowerCase(Locale.US)) 1056 .collect(Collectors.toSet()); 1057 1058 // If Content-Length is already specified, don't do anything 1059 if (normalizedHeaderNames.contains("content-length")) 1060 return marshaledResponse; 1061 1062 // If Content-Length is not specified, specify as the number of bytes in the body 1063 return marshaledResponse.copy() 1064 .headers((mutableHeaders) -> { 1065 String contentLengthHeaderValue = String.valueOf(marshaledResponse.getBody().orElse(emptyByteArray()).length); 1066 mutableHeaders.put("Content-Length", Set.of(contentLengthHeaderValue)); 1067 }).finish(); 1068 } 1069 1070 @Nonnull 1071 protected MarshaledResponse applyCorsResponseIfApplicable(@Nonnull Request request, 1072 @Nonnull MarshaledResponse marshaledResponse) { 1073 requireNonNull(request); 1074 requireNonNull(marshaledResponse); 1075 1076 Cors cors = request.getCors().orElse(null); 1077 1078 // If non-CORS request, nothing further to do (note that CORS preflight was handled earlier) 1079 if (cors == null) 1080 return marshaledResponse; 1081 1082 CorsAuthorizer corsAuthorizer = getSokletConfig().getCorsAuthorizer(); 1083 1084 // Does the authorizer say we are authorized? 1085 CorsResponse corsResponse = corsAuthorizer.authorize(request, cors).orElse(null); 1086 1087 // Not authorized - don't apply CORS headers to the response 1088 if (corsResponse == null) 1089 return marshaledResponse; 1090 1091 // Authorized - OK, let's apply the headers to the response 1092 return getSokletConfig().getResponseMarshaler().forCorsAllowed(request, cors, corsResponse, marshaledResponse); 1093 } 1094 1095 @Nonnull 1096 protected Map<HttpMethod, ResourceMethod> resolveMatchingResourceMethodsByHttpMethod(@Nonnull Request request, 1097 @Nonnull ResourceMethodResolver resourceMethodResolver, 1098 @Nonnull ServerType serverType) { 1099 requireNonNull(request); 1100 requireNonNull(resourceMethodResolver); 1101 requireNonNull(serverType); 1102 1103 // Special handling for OPTIONS * 1104 if (request.getResourcePath() == ResourcePath.OPTIONS_SPLAT_RESOURCE_PATH) 1105 return new LinkedHashMap<>(); 1106 1107 Map<HttpMethod, ResourceMethod> matchingResourceMethodsByHttpMethod = new LinkedHashMap<>(HttpMethod.values().length); 1108 1109 for (HttpMethod httpMethod : HttpMethod.values()) { 1110 // Make a quick copy of the request to see if other paths match 1111 Request otherRequest = Request.withPath(httpMethod, request.getPath()).build(); 1112 ResourceMethod resourceMethod = resourceMethodResolver.resourceMethodForRequest(otherRequest, serverType).orElse(null); 1113 1114 if (resourceMethod != null) 1115 matchingResourceMethodsByHttpMethod.put(httpMethod, resourceMethod); 1116 } 1117 1118 return matchingResourceMethodsByHttpMethod; 1119 } 1120 1121 @Nonnull 1122 protected MarshaledResponse provideFailsafeMarshaledResponse(@Nonnull Request request, 1123 @Nonnull Throwable throwable) { 1124 requireNonNull(request); 1125 requireNonNull(throwable); 1126 1127 Integer statusCode = 500; 1128 Charset charset = StandardCharsets.UTF_8; 1129 1130 return MarshaledResponse.withStatusCode(statusCode) 1131 .headers(Map.of("Content-Type", Set.of(format("text/plain; charset=%s", charset.name())))) 1132 .body(format("HTTP %d: %s", statusCode, StatusCode.fromStatusCode(statusCode).get().getReasonPhrase()).getBytes(charset)) 1133 .build(); 1134 } 1135 1136 /** 1137 * Synonym for {@link #stop()}. 1138 */ 1139 @Override 1140 public void close() { 1141 stop(); 1142 } 1143 1144 /** 1145 * Is either the managed {@link Server} or {@link ServerSentEventServer} started? 1146 * 1147 * @return {@code true} if at least one is started, {@code false} otherwise 1148 */ 1149 @Nonnull 1150 public Boolean isStarted() { 1151 getLock().lock(); 1152 1153 try { 1154 if (getSokletConfig().getServer().isStarted()) 1155 return true; 1156 1157 ServerSentEventServer serverSentEventServer = getSokletConfig().getServerSentEventServer().orElse(null); 1158 return serverSentEventServer != null && serverSentEventServer.isStarted(); 1159 } finally { 1160 getLock().unlock(); 1161 } 1162 } 1163 1164 /** 1165 * Runs Soklet with special non-network "simulator" implementations of {@link Server} and {@link ServerSentEventServer} - useful for integration testing. 1166 * <p> 1167 * See <a href="https://www.soklet.com/docs/testing">https://www.soklet.com/docs/testing</a> for how to write these tests. 1168 * 1169 * @param sokletConfig configuration that drives the Soklet system 1170 * @param simulatorConsumer code to execute within the context of the simulator 1171 */ 1172 public static void runSimulator(@Nonnull SokletConfig sokletConfig, 1173 @Nonnull Consumer<Simulator> simulatorConsumer) { 1174 requireNonNull(sokletConfig); 1175 requireNonNull(simulatorConsumer); 1176 1177 // Create Soklet instance - this initializes the REAL implementations through proxies 1178 Soklet soklet = Soklet.withConfig(sokletConfig); 1179 1180 // Extract proxies (they're guaranteed to be proxies now) 1181 ServerProxy serverProxy = (ServerProxy) sokletConfig.getServer(); 1182 ServerSentEventServerProxy serverSentEventServerProxy = sokletConfig.getServerSentEventServer() 1183 .map(s -> (ServerSentEventServerProxy) s) 1184 .orElse(null); 1185 1186 // Create mock implementations 1187 MockServer mockServer = new MockServer(); 1188 MockServerSentEventServer mockServerSentEventServer = new MockServerSentEventServer(); 1189 1190 // Switch proxies to simulator mode 1191 serverProxy.enableSimulatorMode(mockServer); 1192 1193 if (serverSentEventServerProxy != null) 1194 serverSentEventServerProxy.enableSimulatorMode(mockServerSentEventServer); 1195 1196 try { 1197 // Initialize mocks with request handlers that delegate to Soklet's processing 1198 mockServer.initialize(sokletConfig, (request, marshaledResponseConsumer) -> { 1199 // Delegate to Soklet's internal request handling 1200 soklet.handleRequest(request, ServerType.STANDARD_HTTP, marshaledResponseConsumer); 1201 }); 1202 1203 if (mockServerSentEventServer != null) 1204 mockServerSentEventServer.initialize(sokletConfig, (request, marshaledResponseConsumer) -> { 1205 // Delegate to Soklet's internal request handling for SSE 1206 soklet.handleRequest(request, ServerType.SERVER_SENT_EVENT, marshaledResponseConsumer); 1207 }); 1208 1209 // Create and provide simulator 1210 Simulator simulator = new DefaultSimulator(mockServer, mockServerSentEventServer); 1211 simulatorConsumer.accept(simulator); 1212 } finally { 1213 // Always restore to real implementations 1214 serverProxy.disableSimulatorMode(); 1215 1216 if (serverSentEventServerProxy != null) 1217 serverSentEventServerProxy.disableSimulatorMode(); 1218 } 1219 } 1220 1221 @Nonnull 1222 protected SokletConfig getSokletConfig() { 1223 return this.sokletConfig; 1224 } 1225 1226 @Nonnull 1227 protected ReentrantLock getLock() { 1228 return this.lock; 1229 } 1230 1231 @Nonnull 1232 protected AtomicReference<CountDownLatch> getAwaitShutdownLatchReference() { 1233 return this.awaitShutdownLatchReference; 1234 } 1235 1236 @ThreadSafe 1237 static class DefaultSimulator implements Simulator { 1238 @Nullable 1239 private MockServer server; 1240 @Nullable 1241 private MockServerSentEventServer serverSentEventServer; 1242 1243 public DefaultSimulator(@Nonnull MockServer server, 1244 @Nullable MockServerSentEventServer serverSentEventServer) { 1245 requireNonNull(server); 1246 1247 this.server = server; 1248 this.serverSentEventServer = serverSentEventServer; 1249 } 1250 1251 @Nonnull 1252 @Override 1253 public RequestResult performRequest(@Nonnull Request request) { 1254 AtomicReference<RequestResult> requestResultHolder = new AtomicReference<>(); 1255 Server.RequestHandler requestHandler = getServer().getRequestHandler().orElse(null); 1256 1257 if (requestHandler == null) 1258 throw new IllegalStateException("You must register a request handler prior to simulating requests"); 1259 1260 requestHandler.handleRequest(request, (requestResult -> { 1261 requestResultHolder.set(requestResult); 1262 })); 1263 1264 return requestResultHolder.get(); 1265 } 1266 1267 @Nonnull 1268 @Override 1269 public ServerSentEventRequestResult performServerSentEventRequest(@Nonnull Request request) { 1270 MockServerSentEventServer serverSentEventServer = getServerSentEventServer().orElse(null); 1271 1272 if (serverSentEventServer == null) 1273 throw new IllegalStateException(format("You must specify a %s in your %s to simulate Server-Sent Event requests", 1274 ServerSentEventServer.class.getSimpleName(), SokletConfig.class.getSimpleName())); 1275 1276 AtomicReference<RequestResult> requestResultHolder = new AtomicReference<>(); 1277 ServerSentEventServer.RequestHandler requestHandler = serverSentEventServer.getRequestHandler().orElse(null); 1278 1279 if (requestHandler == null) 1280 throw new IllegalStateException("You must register a request handler prior to simulating SSE Event Source requests"); 1281 1282 requestHandler.handleRequest(request, (requestResult -> { 1283 requestResultHolder.set(requestResult); 1284 })); 1285 1286 RequestResult requestResult = requestResultHolder.get(); 1287 HandshakeResult handshakeResult = requestResult.getHandshakeResult().orElse(null); 1288 1289 if (handshakeResult == null) 1290 return new ServerSentEventRequestResult.RequestFailed(requestResult); 1291 1292 if (handshakeResult instanceof HandshakeResult.Accepted acceptedHandshake) { 1293 Consumer<ServerSentEventUnicaster> clientInitializer = acceptedHandshake.getClientInitializer().orElse(null); 1294 1295 // Create a synthetic logical response using values from the accepted handshake 1296 if (requestResult.getResponse().isEmpty()) 1297 requestResult = requestResult.copy() 1298 .response(Response.withStatusCode(200) 1299 .headers(acceptedHandshake.getHeaders()) 1300 .cookies(acceptedHandshake.getCookies()) 1301 .build()) 1302 .finish(); 1303 1304 HandshakeAccepted handshakeAccepted = new HandshakeAccepted(acceptedHandshake, request.getResourcePath(), requestResult, this, clientInitializer); 1305 return handshakeAccepted; 1306 } 1307 1308 if (handshakeResult instanceof HandshakeResult.Rejected rejectedHandshake) 1309 return new HandshakeRejected(rejectedHandshake, requestResult); 1310 1311 throw new IllegalStateException(format("Encountered unexpected %s: %s", HandshakeResult.class.getSimpleName(), handshakeResult)); 1312 } 1313 1314 @Nonnull 1315 MockServer getServer() { 1316 return this.server; 1317 } 1318 1319 @Nonnull 1320 Optional<MockServerSentEventServer> getServerSentEventServer() { 1321 return Optional.ofNullable(this.serverSentEventServer); 1322 } 1323 } 1324 1325 /** 1326 * Mock server that doesn't touch the network at all, useful for testing. 1327 * 1328 * @author <a href="https://www.revetkn.com">Mark Allen</a> 1329 */ 1330 @ThreadSafe 1331 static class MockServer implements Server { 1332 @Nullable 1333 private SokletConfig sokletConfig; 1334 @Nullable 1335 private Server.RequestHandler requestHandler; 1336 1337 @Override 1338 public void start() { 1339 // No-op 1340 } 1341 1342 @Override 1343 public void stop() { 1344 // No-op 1345 } 1346 1347 @Nonnull 1348 @Override 1349 public Boolean isStarted() { 1350 return true; 1351 } 1352 1353 @Override 1354 public void initialize(@Nonnull SokletConfig sokletConfig, 1355 @Nonnull RequestHandler requestHandler) { 1356 requireNonNull(sokletConfig); 1357 requireNonNull(requestHandler); 1358 1359 this.requestHandler = requestHandler; 1360 } 1361 1362 @Nonnull 1363 protected Optional<SokletConfig> getSokletConfig() { 1364 return Optional.ofNullable(this.sokletConfig); 1365 } 1366 1367 @Nonnull 1368 protected Optional<RequestHandler> getRequestHandler() { 1369 return Optional.ofNullable(this.requestHandler); 1370 } 1371 } 1372 1373 /** 1374 * Mock Server-Sent Event unicaster that doesn't touch the network at all, useful for testing. 1375 */ 1376 @ThreadSafe 1377 static class MockServerSentEventUnicaster implements ServerSentEventUnicaster { 1378 @Nonnull 1379 private final ResourcePath resourcePath; 1380 @Nonnull 1381 private final Consumer<ServerSentEvent> eventConsumer; 1382 @Nonnull 1383 private final Consumer<String> commentConsumer; 1384 1385 public MockServerSentEventUnicaster(@Nonnull ResourcePath resourcePath, 1386 @Nonnull Consumer<ServerSentEvent> eventConsumer, 1387 @Nonnull Consumer<String> commentConsumer) { 1388 requireNonNull(resourcePath); 1389 requireNonNull(eventConsumer); 1390 requireNonNull(commentConsumer); 1391 1392 this.resourcePath = resourcePath; 1393 this.eventConsumer = eventConsumer; 1394 this.commentConsumer = commentConsumer; 1395 } 1396 1397 @Override 1398 public void unicastEvent(@Nonnull ServerSentEvent serverSentEvent) { 1399 requireNonNull(serverSentEvent); 1400 getEventConsumer().accept(serverSentEvent); 1401 } 1402 1403 @Override 1404 public void unicastComment(@Nonnull String comment) { 1405 requireNonNull(comment); 1406 getCommentConsumer().accept(comment); 1407 } 1408 1409 @Nonnull 1410 @Override 1411 public ResourcePath getResourcePath() { 1412 return this.resourcePath; 1413 } 1414 1415 @Nonnull 1416 protected Consumer<ServerSentEvent> getEventConsumer() { 1417 return this.eventConsumer; 1418 } 1419 1420 @Nonnull 1421 protected Consumer<String> getCommentConsumer() { 1422 return this.commentConsumer; 1423 } 1424 } 1425 1426 /** 1427 * Mock Server-Sent Event broadcaster that doesn't touch the network at all, useful for testing. 1428 */ 1429 @ThreadSafe 1430 static class MockServerSentEventBroadcaster implements ServerSentEventBroadcaster { 1431 @Nonnull 1432 private final ResourcePath resourcePath; 1433 @Nonnull 1434 private final Set<Consumer<ServerSentEvent>> eventConsumers; 1435 @Nonnull 1436 private final Set<Consumer<String>> commentConsumers; 1437 1438 public MockServerSentEventBroadcaster(@Nonnull ResourcePath resourcePath) { 1439 requireNonNull(resourcePath); 1440 1441 this.resourcePath = resourcePath; 1442 this.eventConsumers = ConcurrentHashMap.newKeySet(); 1443 this.commentConsumers = ConcurrentHashMap.newKeySet(); 1444 } 1445 1446 @Nonnull 1447 @Override 1448 public ResourcePath getResourcePath() { 1449 return this.resourcePath; 1450 } 1451 1452 @Nonnull 1453 @Override 1454 public Long getClientCount() { 1455 return Long.valueOf(getEventConsumers().size() + getCommentConsumers().size()); 1456 } 1457 1458 @Override 1459 public void broadcastEvent(@Nonnull ServerSentEvent serverSentEvent) { 1460 requireNonNull(serverSentEvent); 1461 1462 for (Consumer<ServerSentEvent> eventConsumer : getEventConsumers()) { 1463 try { 1464 eventConsumer.accept(serverSentEvent); 1465 } catch (Throwable throwable) { 1466 // TODO: revisit this - should we communicate back exceptions, and should we fire these on separate threads for "realism" (probably not)? 1467 // TODO: should probably tie this and other mock SSE types into LifecycleInterceptor for parity once we fully implement metric tracking 1468 throwable.printStackTrace(); 1469 } 1470 } 1471 } 1472 1473 @Override 1474 public void broadcastComment(@Nonnull String comment) { 1475 requireNonNull(comment); 1476 1477 for (Consumer<String> commentConsumer : getCommentConsumers()) { 1478 try { 1479 commentConsumer.accept(comment); 1480 } catch (Throwable throwable) { 1481 // TODO: revisit this - should we communicate back exceptions, and should we fire these on separate threads for "realism" (probably not)? 1482 throwable.printStackTrace(); 1483 } 1484 } 1485 } 1486 1487 @Nonnull 1488 public Boolean registerEventConsumer(@Nonnull Consumer<ServerSentEvent> eventConsumer) { 1489 requireNonNull(eventConsumer); 1490 return getEventConsumers().add(eventConsumer); 1491 } 1492 1493 @Nonnull 1494 public Boolean unregisterEventConsumer(@Nonnull Consumer<ServerSentEvent> eventConsumer) { 1495 requireNonNull(eventConsumer); 1496 return getEventConsumers().remove(eventConsumer); 1497 } 1498 1499 @Nonnull 1500 public Boolean registerCommentConsumer(@Nonnull Consumer<String> commentConsumer) { 1501 requireNonNull(commentConsumer); 1502 return getCommentConsumers().add(commentConsumer); 1503 } 1504 1505 @Nonnull 1506 public Boolean unregisterCommentConsumer(@Nonnull Consumer<String> commentConsumer) { 1507 requireNonNull(commentConsumer); 1508 return getCommentConsumers().remove(commentConsumer); 1509 } 1510 1511 @Nonnull 1512 protected Set<Consumer<ServerSentEvent>> getEventConsumers() { 1513 return this.eventConsumers; 1514 } 1515 1516 @Nonnull 1517 protected Set<Consumer<String>> getCommentConsumers() { 1518 return this.commentConsumers; 1519 } 1520 } 1521 1522 /** 1523 * Mock Server-Sent Event server that doesn't touch the network at all, useful for testing. 1524 * 1525 * @author <a href="https://www.revetkn.com">Mark Allen</a> 1526 */ 1527 @ThreadSafe 1528 static class MockServerSentEventServer implements ServerSentEventServer { 1529 @Nullable 1530 private SokletConfig sokletConfig; 1531 @Nullable 1532 private ServerSentEventServer.RequestHandler requestHandler; 1533 @Nonnull 1534 private final ConcurrentHashMap<ResourcePath, MockServerSentEventBroadcaster> broadcastersByResourcePath; 1535 1536 public MockServerSentEventServer() { 1537 this.broadcastersByResourcePath = new ConcurrentHashMap<>(); 1538 } 1539 1540 @Override 1541 public void start() { 1542 // No-op 1543 } 1544 1545 @Override 1546 public void stop() { 1547 // No-op 1548 } 1549 1550 @Nonnull 1551 @Override 1552 public Boolean isStarted() { 1553 return true; 1554 } 1555 1556 @Nonnull 1557 @Override 1558 public Optional<? extends ServerSentEventBroadcaster> acquireBroadcaster(@Nullable ResourcePath resourcePath) { 1559 if (resourcePath == null) 1560 return Optional.empty(); 1561 1562 MockServerSentEventBroadcaster broadcaster = getBroadcastersByResourcePath() 1563 .computeIfAbsent(resourcePath, rp -> new MockServerSentEventBroadcaster(rp)); 1564 1565 return Optional.of(broadcaster); 1566 } 1567 1568 public void registerEventConsumer(@Nonnull ResourcePath resourcePath, 1569 @Nonnull Consumer<ServerSentEvent> eventConsumer) { 1570 requireNonNull(resourcePath); 1571 requireNonNull(eventConsumer); 1572 1573 MockServerSentEventBroadcaster broadcaster = getBroadcastersByResourcePath() 1574 .computeIfAbsent(resourcePath, rp -> new MockServerSentEventBroadcaster(rp)); 1575 1576 broadcaster.registerEventConsumer(eventConsumer); 1577 } 1578 1579 @Nonnull 1580 public Boolean unregisterEventConsumer(@Nonnull ResourcePath resourcePath, 1581 @Nonnull Consumer<ServerSentEvent> eventConsumer) { 1582 requireNonNull(resourcePath); 1583 requireNonNull(eventConsumer); 1584 1585 MockServerSentEventBroadcaster broadcaster = getBroadcastersByResourcePath().get(resourcePath); 1586 1587 if (broadcaster == null) 1588 return false; 1589 1590 return broadcaster.unregisterEventConsumer(eventConsumer); 1591 } 1592 1593 public void registerCommentConsumer(@Nonnull ResourcePath resourcePath, 1594 @Nonnull Consumer<String> commentConsumer) { 1595 requireNonNull(resourcePath); 1596 requireNonNull(commentConsumer); 1597 1598 MockServerSentEventBroadcaster broadcaster = getBroadcastersByResourcePath() 1599 .computeIfAbsent(resourcePath, rp -> new MockServerSentEventBroadcaster(rp)); 1600 1601 broadcaster.registerCommentConsumer(commentConsumer); 1602 } 1603 1604 @Nonnull 1605 public Boolean unregisterCommentConsumer(@Nonnull ResourcePath resourcePath, 1606 @Nonnull Consumer<String> commentConsumer) { 1607 requireNonNull(resourcePath); 1608 requireNonNull(commentConsumer); 1609 1610 MockServerSentEventBroadcaster broadcaster = getBroadcastersByResourcePath().get(resourcePath); 1611 1612 if (broadcaster == null) 1613 return false; 1614 1615 return broadcaster.unregisterCommentConsumer(commentConsumer); 1616 } 1617 1618 @Override 1619 public void initialize(@Nonnull SokletConfig sokletConfig, 1620 @Nonnull ServerSentEventServer.RequestHandler requestHandler) { 1621 requireNonNull(sokletConfig); 1622 requireNonNull(requestHandler); 1623 1624 this.sokletConfig = sokletConfig; 1625 this.requestHandler = requestHandler; 1626 } 1627 1628 @Nullable 1629 protected Optional<SokletConfig> getSokletConfig() { 1630 return Optional.ofNullable(this.sokletConfig); 1631 } 1632 1633 @Nullable 1634 protected Optional<ServerSentEventServer.RequestHandler> getRequestHandler() { 1635 return Optional.ofNullable(this.requestHandler); 1636 } 1637 1638 @Nonnull 1639 protected ConcurrentHashMap<ResourcePath, MockServerSentEventBroadcaster> getBroadcastersByResourcePath() { 1640 return this.broadcastersByResourcePath; 1641 } 1642 } 1643 1644 /** 1645 * Efficiently provides the current time in RFC 1123 HTTP-date format. 1646 * Updates once per second to avoid the overhead of formatting dates on every request. 1647 */ 1648 @ThreadSafe 1649 private static final class CachedHttpDate { 1650 @Nonnull 1651 private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH) 1652 .withZone(ZoneId.of("GMT")); 1653 @Nonnull 1654 private static volatile String CURRENT_VALUE = FORMATTER.format(Instant.now()); 1655 1656 static { 1657 Thread t = new Thread(() -> { 1658 while (true) { 1659 try { 1660 Thread.sleep(1000); 1661 CURRENT_VALUE = FORMATTER.format(Instant.now()); 1662 } catch (InterruptedException e) { 1663 break; // Allow thread to die on JVM shutdown 1664 } 1665 } 1666 }, "soklet-date-header-value-updater"); 1667 t.setDaemon(true); 1668 t.start(); 1669 } 1670 1671 @Nonnull 1672 static String getCurrentValue() { 1673 return CURRENT_VALUE; 1674 } 1675 } 1676}