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 org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import java.util.function.Consumer; 023 024import static java.lang.String.format; 025 026/** 027 * Simulates server behavior of accepting a request and returning a response without touching the network, useful for writing integration tests. 028 * <p> 029 * <a href="https://www.soklet.com/docs/server-sent-events">Server-Sent Event</a> simulation is also supported. 030 * <p> 031 * Instances of {@link Simulator} are made available via {@link com.soklet.Soklet#runSimulator(SokletConfig, Consumer)}. 032 * <p> 033 * Usage example: 034 * <pre>{@code @Test 035 * public void basicIntegrationTest () { 036 * // Just use your app's existing configuration 037 * SokletConfig config = obtainMySokletConfig(); 038 * 039 * // With the Simulator, you can issue requests 040 * // and receive responses just like you would with real servers. 041 * Soklet.runSimulator(config, (simulator) -> { 042 * // Construct a request 043 * Request request = Request.withPath(HttpMethod.GET, "/hello") 044 * .queryParameters(Map.of("name", Set.of("Mark"))) 045 * .build(); 046 * 047 * // Perform the request and get a handle to the result 048 * HttpRequestResult result = simulator.performHttpRequest(request); 049 * 050 * // Verify status code 051 * Integer expectedCode = 200; 052 * Integer actualCode = result.getMarshaledResponse().getStatusCode(); 053 * assertEquals(expectedCode, actualCode, "Bad status code"); 054 * 055 * // Now, create a request for an SSE Event Source... 056 * Request eventSourceRequest = Request.withPath(HttpMethod.GET, "/sse-test") 057 * .queryParameters(Map.of("signingToken", Set.of("xxx"))) 058 * .build(); 059 * 060 * // ...and perform it and get a handle to the result. 061 * SseRequestResult eventSourceResult = 062 * simulator.performSseRequest(eventSourceRequest); 063 * 064 * // Single-shot latch; we'll wait until a Server-Sent Event comes through 065 * CountDownLatch eventReceivedLatch = new CountDownLatch(1); 066 * 067 * // The Simulator provides 3 logical outcomes for SSE connections: 068 * // * Accepted Handshake (connection stays open) 069 * // * Rejected Handshake (explicit rejection, connection closed) 070 * // * Request Failed (implicit rejection, e.g. uncaught exception, connection closed) 071 * switch (eventSourceResult) { 072 * // Explicit handshake acceptance 073 * case HandshakeAccepted handshakeAccepted -> { 074 * handshakeAccepted.registerEventConsumer((event) -> { 075 * // Server-Sent Event received: open the latch to end the test 076 * eventReceivedLatch.countDown(); 077 * }); 078 * 079 * // On a separate thread, broadcast a Server-Sent Event 080 * new Thread(() -> { 081 * // ... not shown 082 * }).start(); 083 * } 084 * 085 * // Explicit handshake rejection 086 * case HandshakeRejected handshakeRejected -> 087 * Assertions.fail("SSE Handshake Rejected: " + handshakeRejected); 088 * 089 * // Uncaught exception 090 * case RequestFailed requestFailed -> 091 * Assertions.fail("SSE Request Failed: " + requestFailed); 092 * } 093 * 094 * // Finally, wait a bit for the latch to open 095 * try { 096 * eventReceivedLatch.await(5, SECONDS); 097 * } catch (InterruptedException e) { 098 * Assertions.fail("Didn't receive a Server-Sent Event in time"); 099 * } 100 * }); 101 * }}</pre> 102 * <p> 103 * Full documentation is available at <a href="https://www.soklet.com/docs/testing">https://www.soklet.com/docs/testing</a>. 104 * 105 * @author <a href="https://www.revetkn.com">Mark Allen</a> 106 */ 107public interface Simulator { 108 /** 109 * Given a request that would normally be handled by your standard {@link HttpServer}, process it and return response data (both logical {@link Response}, if present, and the {@link MarshaledResponse} bytes to be sent over the wire) as well as the matching <em>Resource Method</em>, if available. 110 * <p> 111 * To make requests that would normally be handled by your {@link SseServer}, use {@link #performSseRequest(Request)}. 112 * 113 * @param request the standard HTTP request to process 114 * @return the result (logical response, marshaled response, etc.) that corresponds to the request 115 */ 116 @NonNull 117 HttpRequestResult performHttpRequest(@NonNull Request request); 118 119 /** 120 * Given a request that would normally be handled by your {@link SseServer} (that is, for a <em>Resource Method</em> decorated with the {@link com.soklet.annotation.SseEventSource} annotation), process it and return response data ({@link com.soklet.SseRequestResult.HandshakeAccepted}, {@link com.soklet.SseRequestResult.HandshakeRejected}, or {@link com.soklet.SseRequestResult.RequestFailed}); 121 * <p> 122 * To make requests that would normally be handled by your {@link HttpServer}, use {@link #performHttpRequest(Request)}. 123 * 124 * @param request the Server-Sent Event HTTP request to process 125 * @return the result (handshake outcode, etc.) that corresponds to the request 126 */ 127 @NonNull 128 SseRequestResult performSseRequest(@NonNull Request request); 129 130 /** 131 * Given a request that would normally be handled by your MCP server, process it and return the corresponding MCP-oriented simulator result. 132 * <p> 133 * The default implementation fails fast until MCP simulator support is wired in. 134 * 135 * @param request the MCP HTTP request to process 136 * @return the MCP simulator result 137 */ 138 @NonNull 139 default McpRequestResult performMcpRequest(@NonNull Request request) { 140 throw new IllegalStateException(format("You must specify an MCP server in your %s to simulate MCP requests", 141 SokletConfig.class.getSimpleName())); 142 } 143 144 /** 145 * Registers a handler for exceptions thrown by simulated Server-Sent Event consumers. 146 * <p> 147 * This only applies to simulator-mode SSE broadcasts. 148 * 149 * @param onBroadcastError handler for broadcast errors, or {@code null} to clear 150 * @return this simulator 151 */ 152 @NonNull 153 default Simulator onBroadcastError(@Nullable Consumer<Throwable> onBroadcastError) { 154 return this; 155 } 156 157 /** 158 * Registers a handler for exceptions thrown by simulated Server-Sent Event unicast consumers. 159 * <p> 160 * This only applies to simulator-mode SSE unicast deliveries (including client initializers). 161 * 162 * @param onUnicastError handler for unicast errors, or {@code null} to clear 163 * @return this simulator 164 */ 165 @NonNull 166 default Simulator onUnicastError(@Nullable Consumer<Throwable> onUnicastError) { 167 return this; 168 } 169 170 /** 171 * Registers a handler for exceptions thrown by simulated MCP stream consumers. 172 * 173 * @param onMcpStreamError handler for MCP stream consumer errors, or {@code null} to clear 174 * @return this simulator 175 */ 176 @NonNull 177 default Simulator onMcpStreamError(@Nullable Consumer<Throwable> onMcpStreamError) { 178 return this; 179 } 180}